- Go 85.9%
- Nix 9.6%
- Makefile 4.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| cmd/tsvpn | ||
| internal | ||
| nix | ||
| systemd | ||
| vendor | ||
| .envrc | ||
| .gitignore | ||
| config.yaml | ||
| devenv.lock | ||
| devenv.nix | ||
| devenv.yaml | ||
| flake.lock | ||
| flake.nix | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
tsvpn
Runs a Tailscale exit node that tunnels all client internet traffic through one or more WireGuard VPN tunnels. Your entire Tailscale mesh gets a single private internet exit point — no VPN client needed on individual devices, and the exit node is never exposed to the internet.
How it works
[Any Tailscale device]
│
▼ tailscale0
[Exit node / tsvpn] ──► policy routing table 51820
│
▼ wg-<profile>
[WireGuard VPN tunnel]
│
▼
[Internet]
tsvpn is a daemon that runs on the exit node. It:
- Brings up all configured WireGuard tunnel interfaces on start
- Installs policy routing rules that intercept traffic arriving from
tailscale0and redirect internet-bound packets into a custom routing table - Points that table at the active tunnel — only one tunnel routes at a time, but all are connected
- Runs health checks on every tunnel and automatically fails over to the next-priority tunnel if the active one degrades
- Serves a web dashboard and CLI for monitoring and manual switching
Traffic between Tailscale peers (e.g. 100.x.x.x ranges) is never sent into the VPN — only internet-bound traffic is redirected. Docker networks are also excluded.
| Rule priority | Match | Action |
|---|---|---|
| 700 | iif tailscale0 → 172.16.0.0/12 (Docker) |
main table |
| 800 | iif tailscale0 → 100.64.0.0/10 (Tailscale peers) |
main table |
| 800 | iif tailscale0 → fd7a:115c:a1e0::/48 (Tailscale IPv6) |
main table |
| 1000 | iif tailscale0 → everything else |
table 51820 → active tunnel |
What you get
- Single internet exit for every device in your Tailscale mesh
- No public IP exposure — the exit node has no internet-facing listener
- VPN privacy for all devices with zero per-device configuration
- Automatic failover — multiple tunnels stay connected; if one drops the next takes over instantly
- Live monitoring — web UI accessible from any device in the mesh, plus a
tsvpn statusCLI command
Dependencies
NixOS
Nothing to install manually. The flake declares everything and Nix resolves it:
| Dependency | Where declared | Who needs it |
|---|---|---|
| Go toolchain | buildGoModule |
Build only |
pkg-config, gtk3, libayatana-appindicator |
buildInputs in flake |
Build + runtime of tsvpn-client (tray) |
wireguard-tools (wg, wg-quick) |
NixOS module | Server (enable = true) |
iproute2 (ip) |
NixOS module | Server (enable = true) |
tailscale |
Your existing NixOS config | Both |
GTK and appindicator are linked into the binary at build time via Nix store paths — the system does not need them installed separately.
Non-NixOS (Make)
Must be installed on the system before building or running:
| Dependency | Purpose |
|---|---|
go ≥ 1.21 |
Build |
pkg-config |
Build (tsvpn-client only) |
gtk3-dev, libayatana-appindicator3-dev |
Build (tsvpn-client only) |
wireguard-tools |
Runtime — wg and wg-quick |
iproute2 |
Runtime — ip rule / ip route |
tailscale |
Runtime — exit node and IP detection |
make build-server (used by make install) skips CGO entirely, so the GTK/appindicator dev packages are only needed if you build the tray variant with make build.
Installation
Option A — NixOS (recommended)
The repo ships a Nix flake with two packages and a shared NixOS module.
| Package | Tray | Systemd service |
|---|---|---|
tsvpn-server |
No | Managed by NixOS (enable = true) |
tsvpn-client |
Yes (GTK) | Optional — can also run daemon manually |
The only real differences between the two packages are the system tray and whether the systemd service is enabled. Both packages contain the full daemon — a client machine can run sudo tsvpn daemon and it works identically to the server. The split exists so headless servers don't pull in GTK.
Add the flake input to your flake.nix:
inputs.tsvpn = {
url = "gitlab:deivi98/tsvpn";
inputs.nixpkgs.follows = "nixpkgs";
};
Server (exit node) — import the module and enable the service:
{ inputs, pkgs, ... }:
{
imports = [ inputs.tsvpn.nixosModules.tsvpn ];
services.tsvpn = {
enable = true; # installs and starts the systemd daemon
# All options below are optional — defaults shown.
web.port = 7171;
web.bind = "auto"; # "auto" resolves to the node's Tailscale IPv4
routing.tableId = 51820;
routing.tailscaleIPv4 = "100.64.0.0/10";
routing.tailscaleIPv6 = "fd7a:115c:a1e0::/48";
routing.dockerCIDR = "172.16.0.0/12";
};
}
Client (desktop in the mesh) — same module, different package, no service:
{ inputs, pkgs, ... }:
{
imports = [ inputs.tsvpn.nixosModules.tsvpn ];
services.tsvpn = {
# enable = false (default — no systemd service)
package = inputs.tsvpn.packages.${pkgs.stdenv.hostPlatform.system}.tsvpn-client;
};
}
In both cases the module:
- Writes
/etc/tsvpn/config.yamlfrom the NixOS options - Creates
/etc/tsvpn/profiles/(writable, fortsvpn import) - Puts
tsvpninPATH
On a client you can point CLI commands at the remote daemon with the -addr flag:
tsvpn status -addr vps.example.com:7171
tsvpn switch -addr vps.example.com:7171 proton-es75
tsvpn tray -addr vps.example.com:7171
Or run the daemon locally on any machine that has WireGuard profiles:
sudo tsvpn daemon
Updating the Go vendor directory after changing dependencies:
go mod vendor
git add vendor/
No Go toolchain is needed on machines that just build from the flake — vendor/ is committed.
Option B — Make (non-NixOS)
Requirements
- Linux with
wireguard-tools(wg,wg-quick) andiproute2 - Tailscale running and configured as an exit node:
tailscale up --advertise-exit-node - IP forwarding enabled:
# /etc/sysctl.conf net.ipv4.ip_forward = 1 net.ipv6.conf.all.forwarding = 1 - Root access (the daemon runs as root via systemd)
Install
From the project directory (inside the dev shell if using devenv):
make install # build binary, install to /usr/local/bin, copy config and profiles, install systemd unit
make enable # enable at boot
make start # start now
make install is safe to re-run — it never overwrites files that already exist in /etc/tsvpn/.
First-time setup (both install methods)
1. Add a VPN profile
Download a WireGuard .conf file from your VPN provider and import it:
tsvpn import ~/Downloads/protonvpn-es75.conf
The import command:
- Enforces
Table = offandDNS = none(required — the daemon owns routing) - Removes any
PostUp/PreDownhooks - Prompts for a name (≤ 15 chars), failover priority, and metadata
- Writes sanitized files to
/etc/tsvpn/profiles/
To add more profiles for failover, import additional configs and give each a different priority (1 = highest).
2. Verify
tsvpn status
Active tunnel: proton-es75
NAME STATUS HANDSHAKE PRIORITY RX/TX PROVIDER
────────────────────────────────────────────────────────────────────────────
proton-es75 healthy 12s ago 1 142M/18M protonvpn (ES) ◀
mullvad-se healthy 8s ago 2 0B/0B mullvad (SE)
Or open the web dashboard from any device in your Tailscale network:
http://<exit-node-tailscale-ip>:7171
Configuration reference
The daemon reads /etc/tsvpn/config.yaml. With NixOS this file is generated from module options; with Make you edit it directly.
profiles_dir: /etc/tsvpn/profiles # where WireGuard profiles are stored
web:
port: 7171 # web UI and API port
bind: auto # "auto" = Tailscale IPv4; or a specific IP / "0.0.0.0"
routing:
table_id: 51820 # policy routing table ID
tailscale_ipv4: "100.64.0.0/10"
tailscale_ipv6: "fd7a:115c:a1e0::/48"
docker_cidr: "172.16.0.0/12" # excluded from VPN routing
Profile format
Profiles live in /etc/tsvpn/profiles/. Each profile is two files:
<name>.conf — standard WireGuard config. Must have Table = off and DNS = none. The interface name is the filename stem (max 15 chars).
[Interface]
PrivateKey = ...
Address = 10.2.0.2/32
Table = off
DNS = none
[Peer]
PublicKey = ...
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = 1.2.3.4:51820
PersistentKeepalive = 25
<name>.meta.yaml — metadata and health check config:
provider: protonvpn
country: ES
server: "ES#75"
priority: 1 # 1 = highest; 0 = not eligible for auto-failover
health:
method: handshake # handshake | ping | all
interval: 15s
max_handshake_age: 180s
ping_target: "1.1.1.1"
failures_before_failover: 3
Priority determines failover order. Profiles without a priority (or priority: 0) are available for manual switching but won't be used in automatic failover.
CLI reference
tsvpn daemon Start the daemon (used by systemd)
tsvpn status Show all tunnel states
tsvpn list List configured profiles
tsvpn switch <name> Manually promote a tunnel to active
tsvpn import <file.conf> Import and sanitize a WireGuard config
All commands except daemon and import talk to the running daemon over HTTP on localhost:7171. Use -addr host:port to override if you changed the port.
Makefile targets
| Target | Description |
|---|---|
make build |
Compile ./bin/tsvpn (with tray, requires CGO + GTK) |
make build-server |
Compile ./bin/tsvpn (no tray, CGO-free) |
make install |
Full install (binary + config + service) |
make install-bin |
Binary only |
make install-config |
Config and profiles only (skips existing files) |
make install-service |
Systemd unit only |
make uninstall |
Remove binary and service (config untouched) |
make start / stop / restart / status |
Control the systemd service |
make enable / disable |
Boot behaviour |
make fmt / vet |
Format and lint |
Project structure
cmd/tsvpn/ CLI entry point and subcommands
internal/config/ Config and profile loading
internal/routing/ ip rule / ip route management
internal/wg/ wg-quick wrapper, handshake and stats queries
internal/health/ Per-tunnel health check goroutine
internal/daemon/ Orchestrator: tunnel lifecycle, failover, stats
internal/api/ HTTP API and embedded web dashboard
nix/ NixOS module (nix/module.nix)
systemd/ tsvpn.service unit file
vendor/ Vendored Go dependencies (no Go toolchain needed to build)