Tundler ("tunnel bundler") packages a small REST API in a Docker image to manage multiple VPN providers. It can rotate tunnels on demand and exposes an HTTP proxy routed through the active VPN.
Unlike other solutions, it depends as much as possible on the VPN providers’ official client libraries to minimise breakage and remains stateless on its own.
- REST API on port
4242for controlling VPN connections. - ExpressVPN, Mullvad, NordVPN, Private Internet Access (PIA) and Surfshark support out of the box.
- Optional HTTP proxy on port
8484with Envoy-based HTTP/HTTPS support. - YAML configuration file for location filtering and debug mode.
- Easily extensible to add new providers.
Tundler uses Linux network namespaces to provide VPN proxy functionality while maintaining API accessibility. The system consists of two isolated network environments within a Docker container:
HOST SYSTEM
┌────────────────────────────────────────────────────────────┐
│ curl --proxy localhost:8484 example.com │
└──────────────────────┬─────────────────────────────────────┘
│ HTTP/HTTPS requests
▼
┌────────────────────────────────────────────────────────────────────┐
│ DOCKER CONTAINER │
│ │
│ ┌───────────────── DEFAULT NAMESPACE ────────────────────────┐ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ │ │
│ │ │ Tundler API │ │ Envoy Proxy │ │ Docker DNS │ │ │
│ │ │ :4242 │ │ :8484 (envoy) │ │ (1.1.1.1) │ │ │
│ │ └─────────────┘ └────────┬────────┘ └──────▲───────┘ │ │
│ │ │ │ DNS │ │
│ │ UID-based mark ──┤ DNS queries ─────┘ │ │
│ │ (fwmark 200) │ exempt from VPN routing │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ vpn-host │ │ │
│ │ │ 172.18.0.1/30 │ │ │
│ │ │ (veth interface) │ │ │
│ │ └─────────────────┬───────────────────┘ │ │
│ └──────────────────────────┼─────────────────────────────────┘ │
│ │ virtual ethernet pair │
│ ▼ │
│ ┌──────────────── VPN NAMESPACE (vpnns) ─────────────────────┐ │
│ │ │ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ vpn-ns + MASQUERADE │ │ │
│ │ │ 172.18.0.2/30 │ │ │
│ │ │ (veth interface) │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ ┌────────────┐ ┌──────────────┐ │ │
│ │ │ ExpressVPN │ │ ... │ │ NordVPN │ │ │
│ │ │ Service │ │ │ │ Service │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ ┌────────┐ │ │ │ │ ┌────────┐ │ │ │
│ │ │ │ tun0 │ │ │ │ │ │nordlynx│ │ │ │
│ │ │ │ IF │ │ │ │ │ │ IF │ │ │ │
│ │ │ └────────┘ │ │ │ │ └────────┘ │ │ │
│ │ └──────────────┘ └────────────┘ └──────────────┘ │ │
│ │ │ │ │
│ └────────────────────────────────┼───────────────────────────┘ │
│ │ VPN tunnel to internet │
│ ▼ │
└────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ INTERNET │
│ (VPN Server) │
└─────────────────┘
-
Proxy requests: Client connects to Envoy on port 8484. Envoy resolves the upstream hostname, then opens a connection to the upstream server. All Envoy traffic is routed through the veth pair into vpnns and forwarded through the active VPN tunnel. By default, DNS queries are resolved outside the tunnel for lower latency. Set
TUNDLER_VPN_DNS=trueto also route DNS through the tunnel for full privacy (see Environment variables). -
API requests: The Tundler REST API on port 4242 stays in the default namespace and is always reachable regardless of VPN state.
docker/build.shCredentials can be provided via environment variables or a .env file at the project root:
# Option 1: Environment variables
EXPRESSVPN_ACTIVATION_CODE=<code> \
MULLVAD_ACCOUNT_NUMBER=<account> \
NORDVPN_TOKEN=<token> \
PRIVATEINTERNETACCESS_USERNAME=<username> \
PRIVATEINTERNETACCESS_PASSWORD=<password> \
SURFSHARK_OPENVPN_USERNAME=<username> \
SURFSHARK_OPENVPN_PASSWORD=<password> \
docker/run.sh
# Option 2: .env file (automatically loaded by run.sh)
cat > .env << 'EOF'
NORDVPN_TOKEN=<token>
MULLVAD_ACCOUNT_NUMBER=<account>
EOF
docker/run.shThe API will be reachable on port 4242 and the HTTP proxy on 8484.
By default, VPN providers run inside their own network namespace.
The TUNDLER_NETNS environment variable specifies the namespace name
(defaults to vpnns). VPN daemons are launched in that namespace using
systemd overrides, while the REST API and proxy stay in the main namespace so they
remain reachable even when the VPN changes routing.
| Provider | Variables |
|---|---|
| ExpressVPN | EXPRESSVPN_ACTIVATION_CODE |
| Mullvad | MULLVAD_ACCOUNT_NUMBER |
| NordVPN | NORDVPN_TOKEN |
| Private Internet Access (PIA) | PRIVATEINTERNETACCESS_USERNAME, PRIVATEINTERNETACCESS_PASSWORD |
| Surfshark (OpenVPN) | SURFSHARK_OPENVPN_USERNAME, SURFSHARK_OPENVPN_PASSWORD |
| Surfshark (WireGuard) | SURFSHARK_WIREGUARD_PRIVATE_KEYS, SURFSHARK_PROTOCOL=wireguard |
| Variable | Default | Description |
|---|---|---|
TUNDLER_VPN_DNS |
false |
Route proxy DNS queries through the VPN tunnel for full privacy |
When present, ~/.config/tundler/tundler.yaml is read at startup:
debug: true
telemetry: false
providers:
- nordvpn:
locations:
- France
- Germanydebugenables verbose logging and may also be set with-d/--debug.telemetryenables anonymous usage statistics (disabled by default). Collects provider, location, and VPN IP (the IP assigned by the VPN, not your real IP). May also be set with--telemetry.providers.<name>.locationsrestricts the random locations used whenlocationis omitted in API calls.loginautomatically authenticates a comma-separated list of providers at startup (allfor every provider).
| Endpoint | Method | Query params | Description |
|---|---|---|---|
/ |
GET | – | List providers and login state |
/connect |
POST | locations, providers (optional) |
Connect to a random location/provider from the list |
/disconnect |
POST | – | Tear down the current tunnel |
/locations |
GET | – | List available locations for logged in providers |
/login |
POST | providers (optional) |
Login comma-separated providers or all when omitted |
/logout |
POST | providers (optional) |
Logout listed providers, or all if empty |
/status |
GET | – | Return tunnel state, IP and provider in use |
- Copy
docker/providers/nordvpntodocker/providers/<your_provider>. - Implement the
install.shandconfigure.shscripts for your provider. - Copy
internal/provider/nordvpntointernal/provider/<your_provider>and implement the interface. - Add a blank import in
internal/provider/register/register.go:
import _ "github.com/laurentpellegrino/tundler/internal/provider/<your_provider>"- Document new environment variables in this README.
Pull requests are welcome.