Sign inSign up

heyfworld/tsvpn

By heyfworld

•Updated 14 days ago

Image
0

151

heyfworld/tsvpn repository overview

⁠tsvpn

A CGO-free Docker image of tsvpn⁠: a Tailscale exit-node helper that sends mesh internet traffic through one or more WireGuard VPN tunnels (Surfshark, Mullvad, Proton, …).

This image is the server binary only (make build-server, no GTK tray). It does not include Tailscale. Pair it with a kernel Tailscale sidecar (or any netns that already has tailscale0).

Platforms: linux/amd64, linux/arm64
Tag: heyfworld/tsvpn:latest

⁠What it does

  • Brings up every WireGuard profile on start
  • Routes only one tunnel at a time (policy table 51820)
  • Leaves Tailscale peer traffic (100.64.0.0/10) and Docker (172.16.0.0/12) on the main table
  • Health-checks tunnels and can fail over by priority
  • Serves a web dashboard to inspect and switch profiles

⁠Layout

tsvpn/
├── docker-compose.yml
├── .env                 # chmod 600; never commit
├── config.yaml
└── data/
    ├── profiles/        # WireGuard .conf + .meta.yaml
    └── tailscale/       # sidecar Tailscale state

⁠Quick start (Tailscale sidecar)

tsvpn must see a kernel tailscale0. The official Tailscale image defaults to userspace — set TS_USERSPACE=false. Share one network namespace:

name: tsvpn-stack

services:
  tailscale:
    image: tailscale/tailscale:stable
    container_name: tsvpn-tailscale
    restart: unless-stopped
    hostname: ${TS_HOSTNAME:-tsvpn-exit}
    environment:
      TZ: ${TZ}
      TS_AUTHKEY: ${TS_AUTHKEY:-}
      TS_STATE_DIR: /var/lib/tailscale
      TS_USERSPACE: "false"
      TS_HOSTNAME: ${TS_HOSTNAME:-tsvpn-exit}
      TS_EXTRA_ARGS: ${TS_EXTRA_ARGS:---advertise-exit-node}
    volumes:
      - ./data/tailscale:/var/lib/tailscale
    devices:
      - /dev/net/tun:/dev/net/tun
    cap_add:
      - NET_ADMIN
      - NET_RAW
    sysctls:
      net.ipv4.ip_forward: "1"
      net.ipv6.conf.all.forwarding: "1"

  tsvpn:
    image: heyfworld/tsvpn:latest
    container_name: tsvpn
    restart: unless-stopped
    network_mode: service:tailscale
    privileged: true
    stop_grace_period: 30s
    depends_on:
      tailscale:
        condition: service_started
    env_file:
      - .env
    environment:
      TZ: ${TZ}
    volumes:
      - ./config.yaml:/etc/tsvpn/config.yaml:ro
      - ./data/profiles:/etc/tsvpn/profiles
    entrypoint: ["/bin/sh", "-euc"]
    command:
      - |
        attempt=0
        until ip link show tailscale0 >/dev/null 2>&1; do
          attempt=$$((attempt + 1))
          [ "$$attempt" -lt 30 ] || exit 1
          sleep 2
        done
        exec /usr/local/bin/tsvpn daemon -config /etc/tsvpn/config.yaml

Do not attach this to a host Tailscale daemon if that host already uses Tailscale for other services. The sidecar is a separate tailnet node.

⁠.env

Create .env next to docker-compose.yml (chmod 600). Never commit live keys.

TZ=
TS_AUTHKEY=
TS_HOSTNAME=tsvpn-exit
TS_EXTRA_ARGS=--advertise-exit-node
VariableDefaultDescription
TZ(set in .env)Container timezone
TS_AUTHKEYemptyTailscale auth key for this sidecar node. Create at https://login.tailscale.com/admin/settings/keys⁠ . Do not invent.
TS_HOSTNAMEtsvpn-exitTailnet hostname of the sidecar (not the Docker host)
TS_EXTRA_ARGS--advertise-exit-nodeExtra tailscale up flags

TS_AUTHKEY must be set before docker compose up. WireGuard private keys stay in ./data/profiles/*.conf, not in .env.

⁠config.yaml

Mount at /etc/tsvpn/config.yaml:

profiles_dir: /etc/tsvpn/profiles

web:
  port: 7171
  bind: 0.0.0.0

routing:
  table_id: 51820
  tailscale_ipv4: "100.64.0.0/10"
  tailscale_ipv6: "fd7a:115c:a1e0::/48"
  docker_cidr: "172.16.0.0/12"

bind: auto runs tailscale ip -4. This image does not ship the Tailscale CLI, so use 0.0.0.0 (listens only inside the sidecar netns).

⁠WireGuard profiles

Each profile is two files in /etc/tsvpn/profiles/. The filename stem is the interface name (max 15 characters).

<name>.conf must have Table = off and DNS = none (tsvpn owns routing):

[Interface]
Address = 10.14.0.2/16
PrivateKey = <redacted>
Table = off
DNS = none

[Peer]
PublicKey = <redacted>
AllowedIPs = 0.0.0.0/0
Endpoint = vpn.example.net:51820

<name>.meta.yaml:

provider: vpn
country: XX
server: "region-1"
priority: 1
health:
  method: handshake
  interval: 15s
  max_handshake_age: 180s
  ping_target: "1.1.1.1"
  failures_before_failover: 3

priority: 1 is highest for auto-failover. 0 (or omit) = manual switch only.

The daemon exits if the profiles directory has no .conf files. After adding or removing profiles, restart the tsvpn container.

⁠Multiple locations

Supported natively. Drop several .conf + .meta.yaml pairs (e.g. name-a, name-b). On start, all tunnels come up; only one is the active default route.

Switch from the dashboard (http://<sidecar-tailscale-ip>:7171) or:

docker compose exec tsvpn tsvpn status
docker compose exec tsvpn tsvpn switch <name>
docker compose exec tsvpn tsvpn off

⁠Dashboard

HTTP, no auth in upstream. Anyone who can reach :7171 on the sidecar Tailscale IP can switch tunnels.

⁠CLI

tsvpn daemon              Start (must run as root)
tsvpn status              Tunnel table
tsvpn list                Profiles on disk
tsvpn switch <name>       Make that tunnel active
tsvpn off                 Stop VPN routing (tunnels stay up)
tsvpn import <file.conf>  Sanitize + write a profile (interactive)

⁠Requirements

  • Linux kernel WireGuard (/dev/net/tun)
  • Root inside the container (wg-quick / ip rule)
  • A kernel tailscale0 in the same netns (TS_USERSPACE=false)
  • At least one profile

⁠Source

Built from gitlab.com/deivi98/tsvpn⁠ with CGO_ENABLED=0 -tags notray. Runtime image is Debian slim + wireguard-tools + iproute2.

Tag summary

Content type

Image

Digest

sha256:7f9696bbf…

Size

35.8 MB

Last updated

14 days ago

docker pull heyfworld/tsvpn