Skip to content

VPN tunnels

A tunnel in The Gateway is any layer-3 path the daemon can route through. Today that means:

  • WireGuard — preferred. In-kernel, fast, easy to script.
  • OpenVPN — supported for providers that don’t offer WireGuard.
  • SOCKS5 — for proxy endpoints (commercial proxies, custom relays, bridge nodes).

Every tunnel is an independent object with its own lifecycle, its own fwmark range, and its own routing table.

  • A managed interface (wgN for WireGuard, tunN for OpenVPN).
  • A dedicated ip rule + routing table — never collides with main.
  • An isolated network namespace (netns) per tunnel via a veth pair — prevents cross-tunnel route bleed and contains any per-tunnel daemon (redsocks, openvpn client) inside its namespace.
  • A latency probe that fires every few seconds and feeds the splitting engine’s “fastest tunnel” selector.
  • A handshake watchdog that surfaces stale tunnels as alerts and (optionally) auto-reconnects.
  • A per-tunnel kill-switch hook: traffic for policies bound to this tunnel drops if the tunnel goes down.
  • Per-tunnel byte counters (RX / TX) for capacity planning.

A WireGuard tunnel, as the daemon stores it:

{
"id": "wg-eu-01",
"name": "EU exit",
"type": "wireguard",
"interface": "wg0",
"listen_port": 51820,
"private_key": "REDACTED_BASE64=",
"address": "10.10.0.2/24",
"peers": [{
"public_key": "REDACTED_BASE64=",
"endpoint": "vpn.example.invalid:51820",
"allowed_ips": ["0.0.0.0/0", "::/0"],
"persistent_keepalive": 25
}]
}

The POST /api/v1/tunnels endpoint only accepts type: wireguard or type: socks5 (per internal/daemon/tunnel_handlers.go:151-156). OpenVPN uses a separate import path — POST /api/v1/tunnels/upload-ovpn with the raw .ovpn blob as the request body. The daemon parses it, runs the client in its own netns, and the tunnel then appears in the regular /tunnels list.

Fail-closed behaviour is not a tunnel attribute — it’s set on the policy or route profile that uses this tunnel. See Routing policies.

For deployments in networks that fingerprint the WireGuard handshake, the daemon supports AmneziaWG — a wire-protocol-compatible WireGuard variant with operator-defined junk-packet padding and header transforms. Each install picks its own parameters; the resulting traffic doesn’t match the canonical WireGuard fingerprint that DPI gear keys on.

Add the obfuscation fields to a WireGuard tunnel create body:

{
"name": "wg-amnezia-eu", "type": "wireguard", "interface": "wg0",
"address": "10.10.0.2/24", "private_key": "REDACTED=",
"peers": [{ "public_key": "REDACTED=", "endpoint": "vpn.example.invalid:51820",
"allowed_ips": ["0.0.0.0/0","::/0"], "persistent_keepalive": 25 }],
"amnezia_jc": 4, "amnezia_jmin": 50, "amnezia_jmax": 1000,
"amnezia_s1": 100, "amnezia_s2": 200,
"amnezia_h1": 1, "amnezia_h2": 2, "amnezia_h3": 3, "amnezia_h4": 4
}

When any of amnezia_jc / amnezia_s1 / amnezia_s2 is non-zero the daemon switches that interface from wireguard-go to amneziawg-go. The peer needs matching parameters — Amnezia configs are not interoperable with vanilla WireGuard peers.

Two independent health signals are tracked per tunnel:

  • Handshake age — for WireGuard, the kernel exposes the last-handshake timestamp. A handshake older than the keepalive interval + grace is flagged.
  • Latency — an ICMP / UDP probe to a configured target inside the tunnel (default: the tunnel’s gateway IP). The result feeds the splitting engine’s endpoint selector and is displayed per-tunnel in the UI.

When a handshake goes stale, the daemon will:

  1. fire an alert (rule tunnel-flap, category tunnel)
  2. cycle the interface (wg-quick down && up)
  3. re-engage the kill-switch for any policy bound to the tunnel if reconnect fails
Method Path Purpose
GET /api/v1/tunnels List with status, RTT, RX/TX
POST /api/v1/tunnels Create a tunnel
GET /api/v1/tunnels/{id} Single tunnel detail
PUT /api/v1/tunnels/{id} Update
POST /api/v1/tunnels/{id}/up Bring up (alias: /connect)
POST /api/v1/tunnels/{id}/down Bring down (alias: /disconnect)
GET /api/v1/tunnels/{id}/config Render the underlying WG / OVPN config
DELETE /api/v1/tunnels/{id} Delete (refuses if in active policy)

A tunnel on its own routes nothing. The natural next moves:

  1. Bind it to traffic via a routing policy — without a policy your new tunnel sits idle.
  2. Add a second tunnel of a different provider, then enable traffic splitting to spread connections across both. Jurisdictional diversity, with one config.
  3. Set fail_policy: fail-closed on the policy that uses it so a stale handshake doesn’t leak to your default route. See Privacy & kill-switch.
  4. Watch the tunnel-flap and tunnel-down alerts — a flaky provider hides easily behind RX/TX counters that look fine in aggregate.