Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

pkarpovich/vpn-exit-node

Repository files navigation

vpn-exit-node

A small, reusable docker-compose appliance that turns any box (a VPS, a home server, a Raspberry Pi) into a controllable Tailscale exit node, optionally with a tailnet-only SOCKS5 proxy and optionally routed out through a VPN tunnel. No application code - just off-the-shelf images wired together.

It is built from two orthogonal axes:

  • Consumer axis - how a device uses the box:
    • Exit node (full tunnel) - select the box as an exit node on any tailnet device (e.g. an Apple TV) to send all its traffic out through the box.
    • SOCKS5 (selective) - a SOCKS5 proxy bound to the box's tailnet address, for app-level routing (e.g. only one service's traffic goes through the box).
  • Egress axis - where the box itself exits to the internet:
    • Physical - the box's own internet connection / location.
    • VPN - via a gluetun tunnel to a target country (OpenVPN or WireGuard).

A single box can combine roles - e.g. a box that is both a selective SOCKS5 for one service and an on-demand exit node for geo-locked content.

The three modes

Mode What you get Egress Command
1 Exit node + tailnet-only SOCKS5 (selective) Physical make up-socks
2 Exit node (full tunnel) Physical make up-exit
3 Exit node routed out through a VPN VPN (gluetun) make up-vpn

Modes are static per deployment - chosen by which compose files/profiles you start, configured via .env. There is no runtime control API.

Quick start

Requirements

  • A 64-bit Linux host with Docker + Compose v2.
  • Kernel-mode Tailscale needs the TUN device and NET_ADMIN. The compose file requests the capability; the device must exist on the host. Quick preflight:
    ls -l /dev/net/tun # must exist
    systemd-detect-virt # expect kvm, or "none" for bare metal; NOT openvz/lxc
    docker compose version # v2.x
    Container-based virtualization (OpenVZ/LXC) usually has no /dev/net/tun and cannot run the exit node.

Steps

  1. Get the code on the box and enter it:
    git clone https://github.com/pkarpovich/vpn-exit-node.git
    cd vpn-exit-node
  2. Copy and fill the environment file:
    cp .env.example .env
    # edit .env: set TS_AUTHKEY and TS_HOSTNAME (and VPN vars for mode 3)
  3. Generate a Tailscale auth key at https://login.tailscale.com/admin/settings/keys (see Auth keys below).
  4. Start the mode you want:
    make up-exit # mode 2: exit node only
    make up-socks # mode 1: exit node + SOCKS5
    make up-vpn # mode 3: exit node via VPN
  5. Approve the exit-node route in the Tailscale admin console (Machines -> the box -> Edit route settings -> enable "Use as exit node"), unless you have set up autoApprovers (see Tailnet ACL).

make is just a thin wrapper; the equivalent raw commands are:

docker compose up -d # mode 2
docker compose --profile socks up -d # mode 1
docker compose -f compose.yml -f compose.vpn.yml up -d # mode 3
docker compose down --remove-orphans # stop (make down)
docker compose logs -f # follow logs (make logs)

To run mode 1 and mode 3 together (selective SOCKS over a VPN egress), use make up-socks-vpn, or the equivalent raw command - add the profile to the VPN composition:

docker compose -f compose.yml -f compose.vpn.yml --profile socks up -d

Run commands per mode

Mode 2 - exit node, physical egress

make up-exit

Starts only the tailscale service. It advertises itself as an exit node (--advertise-exit-node) in kernel mode for full streaming throughput. Select the box as an exit node on any tailnet device.

Mode 1 - selective SOCKS5, physical egress

make up-socks

Starts tailscale plus the socks5 service (compose profile socks). The proxy shares tailscale's network namespace and is bound only to the box's 100.x tailnet address - reachable from the tailnet, never from the public internet. Point an app at socks5h://<box-tailnet-ip>:1080.

Note: the SOCKS5 image (vimagick/microsocks) is amd64-only. On ARM hosts (e.g. a Raspberry Pi or an ARM VPS) it runs under emulation if at all; modes 2 and 3 use multi-arch images and are unaffected.

Mode 3 - exit node via VPN egress

make up-vpn

Adds the gluetun service from compose.vpn.yml. gluetun becomes the network- namespace owner and tailscale joins it (network_mode: service:gluetun), so all exit-node traffic leaves through the VPN tunnel. gluetun's killswitch stays on, and a route-fix sidecar repairs the tailnet return path (see VPN killswitch).

Environment reference

All configuration lives in .env (copy from .env.example). .env is gitignored - never commit real keys or VPN credentials.

Core Tailscale (every mode)

Variable Default Description
TS_AUTHKEY (required) Tailscale auth key. Prefer a reusable key (or OAuth client) tagged tag:exit-node; see Auth keys.
TS_HOSTNAME vpn-exit-node Node name on the tailnet (also the Docker container name). Unique per box.
TS_EXTRA_ARGS --advertise-exit-node Extra tailscale up args. Keep --advertise-exit-node for exit-node modes; add --advertise-tags=tag:exit-node when registering with an OAuth client (required - see Auth keys).

SOCKS5 (mode 1, profile socks)

Variable Default Description
SOCKS_PORT 1080 Port the SOCKS5 proxy listens on (bound to the 100.x address only).
SOCKS_USER (empty) Optional SOCKS5 username. Leave both blank for unauthenticated tailnet-only access.
SOCKS_PASS (empty) Optional SOCKS5 password. Set together with SOCKS_USER.
TS_IFACE tailscale0 Advanced: interface the entrypoint reads the 100.x address from.
SOCKS_WAIT_RETRIES 60 Advanced: number of 2s polls to wait for the tailnet IPv4.

VPN egress via gluetun (mode 3, compose.vpn.yml)

Variable Default Description
VPN_SERVICE_PROVIDER (required for mode 3) gluetun provider, e.g. mullvad, nordvpn, protonvpn, or custom. See the gluetun wiki.
VPN_TYPE openvpn Tunnel protocol: openvpn or wireguard.
SERVER_COUNTRIES (empty) Target egress country, provider-dependent, e.g. Netherlands.
OPENVPN_USER (empty) OpenVPN username (VPN_TYPE=openvpn).
OPENVPN_PASSWORD (empty) OpenVPN password (VPN_TYPE=openvpn).
OPENVPN_CUSTOM_CONFIG (empty) For VPN_SERVICE_PROVIDER=custom: path to a .ovpn mounted under ./vpn-files, e.g. /gluetun/custom/myconfig.ovpn.
WIREGUARD_PRIVATE_KEY (empty) WireGuard private key (VPN_TYPE=wireguard).
WIREGUARD_ADDRESSES (empty) WireGuard interface addresses (VPN_TYPE=wireguard).
FIREWALL_OUTBOUND_SUBNETS (empty) LAN subnets the box may reach bypassing the VPN (comma-separated), e.g. 192.168.1.0/24. Do not put the tailnet range here - it breaks the return path (see VPN killswitch). Never set FIREWALL=off.
TZ UTC Container timezone (affects gluetun log timestamps).

Security posture

The box is tailnet-only by design:

  • No published ports. Neither compose.yml nor compose.vpn.yml declares a ports: mapping, so nothing is exposed on the host's public interface.
  • SOCKS5 binds to 100.x only. scripts/socks-entrypoint.sh waits for the tailnet IPv4 on tailscale0 and binds microsocks to that address - never 0.0.0.0. In the shared namespace the box's public interface is also present, so binding to the Tailscale address is what keeps the proxy off the internet.
  • Access is governed by your tailnet ACL (see below).

Optional host hardening

Defense in depth, not required for the tailnet-only posture: a host firewall allowing inbound only Tailscale (UDP 41641) and SSH. Everything else - the exit-node forwarding and the SOCKS proxy - happens inside the container namespace and never needs an open host port.

VPN killswitch (mode 3)

gluetun ships a killswitch (FIREWALL=on by default) that drops all traffic not going through the tunnel - so a tunnel drop cannot leak your real IP. Leave it on; never set FIREWALL=off.

Return-path routing (the route-fix sidecar)

The exit-node return path is a routing problem, not a firewall one. In the shared namespace gluetun pushes the tailnet CGNAT range out the VPN tunnel via low-numbered ip rules - its default-via-tunnel rule, plus a priority-99 rule for anything in FIREWALL_OUTBOUND_SUBNETS. All of these outrank Tailscale's own priority-5270 / table-52 rule, so replies to tailnet peers (destined to 100.64.0.0/10) are sent out the wrong interface and black-hole.

compose.vpn.yml therefore runs a tiny route-fix sidecar in gluetun's netns that reinstates a higher-priority (lower-numbered) rule sending the tailnet ranges to Tailscale's table 52, ahead of gluetun's rules:

ip rule add to 100.64.0.0/10 table 52 priority 90
ip -6 rule add to fd7a:115c:a1e0::/48 table 52 priority 90

It reuses the already-pulled tailscale/tailscale image (for iproute2), starts no tailscaled, and the rule is a no-op if table 52 has no matching route, so it cannot regress behavior. This still warrants a live check on first deploy, since the gluetun-vs-Tailscale iptables/nftables interaction cannot be exercised in CI (see Manual verification per mode).

FIREWALL_OUTBOUND_SUBNETS is not part of this fix. It is an OUTPUT-chain allowance for LAN subnets you want the box to reach while bypassing the VPN (e.g. 192.168.1.0/24); leave it empty otherwise. Do not put the tailnet range there - it does not cover the forwarded return traffic, and its priority-99 route is one of the rules the sidecar has to override.

Auth keys / key expiry

Exit nodes are long-lived, and two separate expiries are in play:

  • The auth key that registers the node. All auth keys expire (90 days max - reusable or not, they cannot be made non-expiring). compose.yml sets TS_AUTH_ONCE=true, so with the persisted state volume the node authenticates once and does not re-run tailscale up on restart. That sidesteps a known failure where restarting with an expired key drops an already-authenticated node (tailscale#19501). Use a reusable key so a future re-auth (e.g. after recreating the state volume) still works while the key is within its 90-day window.
  • The node's key expiry, which is what actually keeps the box on the tailnet long-term. Either disable key expiry for the node in the admin console (Machines -> the box -> Disable key expiry) or register with an OAuth client (whose credentials do not expire), as below.

Registering with an OAuth client (non-expiring credentials)

OAuth client secrets do not expire, so they are the durable option for a long-lived node or a fleet. Tailscale requires OAuth-registered nodes to be tagged, so the default reusable-key config is not enough on its own - you must pass the tag and opt out of the ephemeral default:

  1. Create an OAuth client with the auth_keys write scope and assign it tag:exit-node (Tailscale OAuth registration).
  2. In .env, use the client secret as the auth key with ?ephemeral=false (without it the node registers as ephemeral and is removed from the tailnet when the container stops), and advertise the tag:
    TS_AUTHKEY=tskey-client-xxxxx?ephemeral=false
    TS_EXTRA_ARGS=--advertise-exit-node --advertise-tags=tag:exit-node

Tag the key (e.g. tag:exit-node) so autoApprovers can approve the route automatically.

Tailnet ACL (optional)

Add an autoApprovers entry so tagged exit nodes are approved without a manual click - handy when expanding a fleet. Edit your tailnet policy file (Access controls) - it is HuJSON (JSON with comments and trailing commas):

// acl.hujson
{
 "tagOwners": {
 "tag:exit-node": ["autogroup:admin"],
 },
 // Zero-click: any node tagged tag:exit-node is auto-approved as an exit node.
 "autoApprovers": {
 "exitNode": ["tag:exit-node"],
 },
}

Optional granular ACL - restrict who may reach the SOCKS5 proxy on the box (here, only nodes tagged tag:sync may reach port 1080):

{
 "acls": [
 {
 "action": "accept",
 "src": ["tag:sync"],
 "dst": ["tag:exit-node:1080"],
 },
 ],
}

Manual verification per mode

Requires real infrastructure (a tailnet, the box, and - for mode 3 - VPN credentials). Run from another tailnet host unless noted.

  • Mode 1 (selective SOCKS, physical):

    curl --socks5 <box-tailnet-ip>:1080 https://api.ipify.org

    Expect the box's public IP (i.e. the box's own country). No second device? Run the check on the box itself, inside the proxy's network namespace:

    docker run --rm --network container:<TS_HOSTNAME> curlimages/curl:latest \
     -s --socks5-hostname <box-tailnet-ip>:1080 https://api.ipify.org
  • Mode 2 (exit node, physical): select the box as the exit node on a device (e.g. an Apple TV), then confirm the device's public IP shows the box's country.

  • Mode 3 (exit node via VPN):

    docker compose -f compose.yml -f compose.vpn.yml exec gluetun wget -qO- ipinfo.io

    Expect the VPN country. Then route a consumer device through the box as its exit node and confirm it both reaches the internet (this exercises the tailnet return path the route-fix sidecar repairs - see VPN killswitch) and shows the VPN country. If replies black-hole, check docker compose -f compose.yml -f compose.vpn.yml exec gluetun ip rule shows the priority-90 rule to table 52. Finally stop the gluetun container and confirm there is no leak (the killswitch holds and traffic stops).

Non-goals

  • Multi-hop chaining (traffic through box B -> box A -> internet). Tailscale does not support exit-node-through-exit-node, so it is not built here. It is possible later via a stacked gluetun config, but out of scope.
  • A runtime control API / UI - mode and country are set per deployment via .env + compose profiles/overrides.

History

This is a ground-up rewrite. The previous version was a Node/TS HTTP controller that ran tailscaled and OpenVPN inside one privileged container; it is preserved at tag v1.0.2 and in the git history before the rewrite. The current appliance is pure docker-compose with no application code, so the old npm dependencies - and their Dependabot/Snyk update PRs - no longer apply.

License

See LICENSE.

About

Tailscale Exit Node & OpenVPN Controller: Automate and manage your traffic exit node with Tailscale integration and easily toggle OpenVPN for enhanced privacy and security

Topics

Resources

License

Stars

Watchers

Forks

Packages

Contributors

AltStyle によって変換されたページ (->オリジナル) /