Blog › Networking
A Tailscale exit node that leaves through Mullvad
The whole reason I set up Tailscale was so I could get to my VM server and my NAS from anywhere, without opening a single port on my home router. Once that was working, I got greedy: if Tailscale is already on all my devices anyway, why not have my Raspberry Pi act as an exit node and send everything out through Mullvad?
0. Why Tailscale in the first place
My VM server is a Ryzen desktop at home running Linux and KVM, and the NAS sits on the same home network. Before Tailscale, getting to either from outside the house meant port forwarding, which I really didn't want to do. Now the VM server just runs Tailscale like my phone and laptop do, so it's on my private network wherever I am:
phone / laptop (anywhere)
│ Tailscale (WireGuard)
▼
VM server ──▶ its VMs (subnet route)
│ home LAN
▼
NAS
- The VM server is a normal Tailscale device, so I can remote straight into it.
- The VMs live on a virtual network inside the VM server. The server advertises that network to Tailscale as a subnet route, so I can reach the VMs directly too, not just the host.
- The NAS is only reachable from the VM server, over the home LAN. I like it that way: one locked-down box is the only way in, instead of putting the NAS itself on the network.
None of that needs the Pi. The Pi is the bonus round: I wanted to tap one button on my phone and have everything go through a VPN, from whatever sketchy coffee shop Wi-Fi I'm on, without installing a VPN app on every single device I own. For that part I gave myself three rules:
- One Mullvad server at a time. It gets picked at random, and then it stays until I change it. That way sites don't see me teleporting between cities every five minutes.
- Fail closed. If the tunnel dies, traffic from my devices just stops. It's never allowed to quietly fall back to my home connection.
- Switchable from my phone. I'm not opening an SSH session every time I want a different server.
phone / laptop
│ Tailscale (WireGuard)
▼
Pi: tailscale0
│ nftables: tailscale0 → wg-* only
▼
wg-sea-003
│ WireGuard
▼
Mullvad ──▶ internet
1. The server pool
Mullvad lets you download a WireGuard config for every one of their servers, so I grabbed them. I ended up keeping 209, spread across 21 locations, all sitting in /etc/wireguard/wg-<city>-<nnn>.conf. They all use the same private key and tunnel address, which means switching servers is really just swapping out who's on the other end.
I also deleted the DNS = line from every config. DNS gets handled in the firewall instead (more on that in a sec), and I didn't want wg-quick rewriting resolv.conf every time I switched.
The actual switching is done by a tiny script I called vpn-random. It kills whatever tunnel is up and brings up either the server you named or a random one:
# hold the watchdog's lock so it can't start a tunnel mid-switch
[ -n "$VPN_LOCKED" ] || exec env VPN_LOCKED=1 flock /run/vpn-watchdog.lock "$0" "$@"
...
conf=$(ls /etc/wireguard/wg-*.conf | shuf -n 1)
vpn-tailnet-routes --clear # more on this one below
for i in $(wg show interfaces); do wg-quick down "$i"; done
wg-quick up "$(basename "$conf" .conf)"
vpn-tailnet-routes
logger -t vpn-random "now using $(basename "$conf" .conf)"
If you do give it a server name, it has to look like wg-[a-z]{3}-[0-9]{3} or the script refuses. That script can run with elevated rights, so I really didn't want anything weird ending up in a file path.
2. The kill switch
The whole firewall is nftables. The forward chain is where the kill switch lives, and it's pretty much one line: traffic from the tailnet is only allowed out through a Mullvad interface. No wg-* interface, nothing matches, and the default policy drops it. Done.
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
iifname "tailscale0" oifname "wg-*" accept # kill switch
}
I gave the Pi's own traffic the same treatment in the output chain. The only things allowed out eth0 are the LAN, Tailscale's own connections (tailscaled marks those), and the encrypted WireGuard packets themselves (wg-quick marks those). Everything else gets dropped:
chain output {
type filter hook output priority 0; policy drop;
oifname { "lo", "tailscale0" } accept
oifname "wg-*" accept
meta mark 0x0000ca6c accept # wg-quick: encrypted UDP to Mullvad
meta mark & 0x00ff0000 == 0x00080000 accept # tailscaled: control, DERP, peers
ip daddr $LAN4 accept
...
counter drop
}
ct state established accept in the output chain, and that's on purpose. When the tunnel goes down or I switch servers, the routes into it disappear, and the next packet of an already-open connection would head out eth0 instead. With that rule, the firewall would wave it through just because the connection already existed. Without it, every packet has to match an interface rule, so the kill switch holds even mid-switch. And nothing breaks: replies come in through the input chain, and every legit outgoing packet already matches one of the rules above.DNS was the other leak I had to plug, and it turned out to have two doors. The first is plain DNS (port 53) that my devices send through the Pi. The firewall grabs all of it and redirects it to Mullvad's resolver inside the tunnel:
iifname "tailscale0" meta l4proto { tcp, udp } th dport 53 dnat ip to 10.64.0.1
The second door is sneakier. When a device uses an exit node, Tailscale doesn't send plain DNS at all. It sends lookups to a little DNS service that tailscaled runs on the Pi, and that service just asks whatever resolver the Pi itself uses. So the Pi's own /etc/resolv.conf has to point at Mullvad (10.64.0.1) too.
/etc/resolv.conf with the router's DNS every time it renews its lease, so for a while every lookup from my phone went Pi → home router → Google DNS, completely outside the tunnel. The fix was one line, RESOLV_CONF="no" in /etc/udhcpc/udhcpc.conf. To double-check, I looked up whoami.akamai.net, which answers with the IP of whatever resolver asked it. Through every path, it's now a Mullvad server in the same data center as my exit IP. Through my router, it's Google. Turns out the config I wrote and the config I actually had weren't the same thing, so now I test where my lookups really go instead of assuming.Bonus: sshd is turned off entirely. The only way into the Pi is Tailscale SSH, so there's no admin port sitting open on my LAN.
3. The routing trap: wg-quick always wins
This is the part that took me the longest to figure out, by a lot. The tunnel was up, my phone could connect to the exit node... and then nothing. No replies, ever.
Turns out it's because of how wg-quick handles AllowedIPs = 0.0.0.0/0. It creates its own routing table, numbered after the tunnel's firewall mark (0xca6c, which is table 51820), and puts a default route in it. Then it adds ip rules that send every unmarked packet to that table. So when the Pi tried to reply to my phone (anything in 100.64.0.0/10), the reply went into the Mullvad tunnel instead of back out tailscale0. Oops.
My first idea was to add my own higher-priority ip rule for tailnet addresses. Doesn't work. wg-quick shoves its rules in ahead of yours every single time it comes up. So I gave up fighting it and put my tailnet routes inside wg-quick's own table instead:
# vpn-tailnet-routes: wg-quick uses the same number for the fwmark and the table
table=$(( $(wg show "$iface" fwmark) ))
ip -4 route replace 100.64.0.0/10 dev tailscale0 table "$table"
ip -6 route replace fd7a:115c:a1e0::/48 dev tailscale0 table "$table"
That fixed it, but it came with two gotchas:
- You have to remove those routes before
wg-quick down. If you don't, the table isn't empty, so the nextwg-quick updecides 51820 is taken and quietly uses 51821. Now your routes are in the wrong table and you're back to square one. That's whyvpn-randomruns--clearfirst. - tailscaled sometimes recreates
tailscale0, and the routes vanish with it. The watchdog puts them back every minute.
Same family of bug, smaller scale: wg-quick also sends 255.255.255.255 into the tunnel. My Wake-on-LAN script for another machine at home just stopped working, and the fix was pointing it at the subnet broadcast address instead.
4. Keeping it up
A cron job runs a watchdog every minute. It shares a lock with vpn-random, so the two of them can never be messing with the tunnel at the same time:
* * * * * flock -n /run/vpn-watchdog.lock /usr/local/bin/vpn-watchdog
If no tunnel is up, it picks a random server. If a tunnel is up but two pings to 1.1.1.1 through it both fail, it assumes the server is dead and switches. A healthy server never gets touched, which is what makes the pick "sticky". And if I'm doing maintenance, creating /run/vpn-watchdog.off pauses it.
5. Which servers actually work?
A random server isn't much fun if half the internet throws CAPTCHAs at it. So I wrote vpn-check, which tests every server without touching the tunnel I'm actually using. It creates a throwaway WireGuard interface, moves it into its own network namespace, and points it at each server one by one. From inside the namespace it grabs the exit IP and checks whether Reddit or Google treat it differently.
One detail I like: the interface gets created in the normal namespace first and only moved afterward. That way its encrypted packets still go out eth0 with wg-quick's mark, so the kill switch lets them through. Here's what the last full run found:
So 51 servers work for everything I use. Reddit really does not like VPNs. The phone switcher shows these results next to each server so I don't pick a dud.
6. A switcher for my phone
The last piece is a little Python web page (standard library only, no frameworks) for switching servers. I can pick a random one, a random one in a region, or a specific one, and I can even upload a new WireGuard config. I built it assuming that someday something will reach it that shouldn't:
- It only listens on the Pi's Tailscale address, over HTTPS with a certificate from
tailscale certthat renews daily. - Every request gets checked with
tailscale whois, so only my Tailscale account gets in, not just any device that happens to be on the tailnet. - The
Hostheader has to be one of the Pi's own names, which blocks DNS rebinding. Every POST also needs a CSRF token. - The page runs as an unprivileged user. The only privileged thing it can do is call one helper,
vpn-ctl, throughdoas. vpn-ctlnever copies an uploaded config as-is. It reads only the fields it knows, validates them, and writes a fresh file. This matters more than it sounds: aPostUp =line in a WireGuard config runs as root whenwg-quickbrings the tunnel up, so a raw upload would basically be "run any command as root".
Trade-offs I'm okay with
- DNS on the Pi itself only works while a tunnel is up, because its resolver is Mullvad's. The watchdog doesn't need DNS, so the whole thing can still fix itself.
- One key for everything. It keeps the configs simple, but if Mullvad gets annoyed at that key (see: the rate limit thing), my real tunnel feels it too.
- Two layers of WireGuard cost a bit of speed and latency compared to running Mullvad directly on each device. In exchange, every device I own is covered by one toggle it already has.