In the last few months, I've done a lot of travelling, having spoken at Djangcon Europe in Northern Spain Wagtail Space the The Netherlands and Django London just last week. When I travel anywhere, international or otherwise, I use a VPN. Regardless of the network I'm on, I know my connection is secure (as secure as any connection can be), without being snooped on or at the mercy of whatever network configuration it imposes.
My VPN of choice is Mullvad. I've used them for years and I really couldn't be happier. It's cheap, featureful and mostly open-source.
On my laptop, I use the desktop client. Sure, I could just download the configs and use them directly, but the features and ease of use make it hard not to use.
However, there's a problem with it: It doesn't work with Tailscale. I recently started using Tailscale (via Headscale) as a mesh network to connect all my devices and get into my home network. That means, if I want to SSH into my servers, or access a non-web-accessible services, I need access to Tailscale. Unfortunately, when Mullvad is connected, Tailscale doesn't work.
$ ping 100.64.0.7
...
If you use Tailscale's controlplane, logging in to tailscale.com, you get a nice fancy integration with Mullvad, whereby you can easily be connected to both Tailscale and Mullvad. Mullvad's servers just appear as additional exit nodes for you to connect through. But, as an avid self-hoster, I really didn't like the idea of giving complete access to the network connecting all my devices to another company - I want to control it myself. Which I can do with Headscale, but that means the Mullvad integration isn't available to me.
Currently, if I'm connected to Tailscale (which my devices always are), and try to connect to Mullvad, the connection fails. Mullvad takes priority for outgoing connections, meaning the Tailscale traffic gets sent to Mullvad's servers, and dropped.
<note
Tailscale uses Wireguard, meaning any data which is sent to Mullvad's servers is fully encrypted. Even then, I doubt it gets past a handshake.
</note>
Mullvad supports allowing access to LAN traffic, which I took advantage of for my previous Nebula and Wireguard based setups. But, Tailscale uses the Carrier-Grade NAT range (100.64.0.0/10), which isn't technically LAN traffic - because it's not. If you do need Carrier-Grade NAT to get online, Mullvad supporting this would disable your access to the internet.
So, how can I achieve what I want. How can I be connected to Tailscale and Mullvad at the same time?
#Modify the binary?
When I asked about this on Mullvad's issue tracker, the first recommendation I received was to modify the binary. Inside the Mullvad app's source code is a list of address blocks it considers "LAN". Adding the Tailscale addresses to this list would force Mullvad to consider them "LAN" too, stop routing them to its servers, and thus let traffic flow correctly through Tailscale.
It's only a single-line change, which I could probably maintain myself quite easily, but it's 1 line more than I want to. Whenever the Mullvad team release an update, I need to apply my patch, and re-compile the app from source. For a small quality-of-life improvement, that's more than I want to deal with, and something I'll almost certainly forget about in time. Mullvad moves surprisingly quickly for a VPN, so it's a change I'd have to keep applying and updating pretty frequently.
#Marking traffic
Mullvad's apps support "split tunneling" - allowing the traffic from specific app to be routed normally, rather than through the tunnel. To achieve this, Mullvad uses traffic marking to control how traffic is routed by the kernel. If traffic is marked in a particular way, it won't be routed through Mullvad, regardless of the state of the tunnel. Because this kind of split tunneling is a common use-case, Mullvad even document it themselves.
This trick only works on Linux. Android (and iOS I suspect too) only let you connect to a single VPN at once, so it's not possible to use both unless you setup split tunneling with Wireguard yourself (and thus can't use Tailscale anyway). I'm sure there's a way to make it work on other platforms, I just don't use them.
#Configuring your firewall
Whenever someone talks about linux firewalling, they generally use iptables. I, like most people, hate using iptables, and find it a pain to manage. I avoid funky networking whenever possible, but sometimes it needs to be done. Instead, nftables (or nft - not that NFT) is newer, easier to manage, and is usually the kernel subsystem iptables uses anyway.
With iptables, everything is configured at the command line, and persisted using iptables-persistent. With nftables, you can do that, but it's easier and clearer to write what you need in a file and load it. Before starting to use nftables, be sure to check the /etc/nftables.conf file for its default rules, and tweak them to match your needs (or remove it entirely if you don't need them or want to deal with them later).
With nftables, it's just a case of writing a simple config file, and saving it somewhere sensible:
mullvad-tailscale.conf
table inet mullvad_tailscale {
chain output {
type route hook output priority 0; policy accept;
ip daddr 100.64.0.0/10 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
}
}
The syntax is fairly readable, but there's a lot of keywords which might be confusing. This defines a network table called "mullvad_tailscale", and configures an output chain ("chain" meaning the same here as it does with iptables), which defaults to allowing all traffic out ("policy accept"). Next, traffic with a destination (daddr) of our Tailnet (100.64.0.0/10) is marked with certain special values. These values ensure the traffic passes through the firewall and isn't routed by Mullvad. Since Mullvad isn't handling the traffic, your device will fall back to the routing table, where it'll be picked up by Tailscale and routed to its intended destination.
There's far more on nftables I could cover, but their documentation is, whilst verbose, a good starting point. For now, you can assume what I'm saying is true, and works.
#Testing
With the above rules saved wherever you like, they can be loaded from the terminal:
$ sudo nft -f mullvad_tailscale.conf
Now, you're still connected to Mullvad, which can be confirmed with their API:
$ curl https://am.i.mullvad.net/connected
You are connected to Mullvad (server gb-lon-wg-xxx). Your IP address is xxx.xxx.xxx.xxx
But now, instead of never getting a response, you can ping your Tailnet, and traffic will flow:
$ ping 100.64.0.7
PING 100.64.0.7 (100.64.0.7) 56(84) bytes of data.
64 bytes from 100.64.0.7: icmp_seq=1 ttl=64 time=5.95 ms
64 bytes from 100.64.0.7: icmp_seq=2 ttl=64 time=1.30 ms
64 bytes from 100.64.0.7: icmp_seq=3 ttl=64 time=3.90 ms
64 bytes from 100.64.0.7: icmp_seq=4 ttl=64 time=3.17 ms
64 bytes from 100.64.0.7: icmp_seq=5 ttl=64 time=1.76 ms
64 bytes from 100.64.0.7: icmp_seq=6 ttl=64 time=3.37 ms
For devices on the same network, notice the ping times are low. This shows traffic is routing directly device-to-device, rather than doing via Mullvad's servers, which keeps latency down and avoids extra unnecessary network hops. Since Mullvad already identifies these connections as local, they already bypass the need to go to Mullvad's servers.
This means I can be connected to the internet over a secure Mullvad tunnel, but still access my Tailscale resources.
#Removing the markings
With the rules in place, traffic will flow as you expect it. However, you may want to disable the rule to restore normality, if the network is particularly strange. Again, thanks to nftables, that's simple too:
$ sudo nft delete table inet mullvad_tailscale
This command removes the "table" created before, and with it the chain which marks traffic. After running, connections to Tailscale immediately stop working, but Mullvad continues.
<note
mullvad_tailscale matches the name of the "table" defined in mullvad_tailscale.conf, as opposed to the name of the file.
</note>
#Automation
With these 2 commands, it's possible to fix and then break (restore to previous functionality) the connection to both Tailscale and Mullvad. But the process is still manual - I have to run a command each time. Ideally, since these commands shouldn't impact regular traffic, so ideally the extra marks would always be in place, avoiding the need to run extra commands.
nftables ships with a service, which automatically loads the /etc/nftables.conf file on boot. Here, it's possible to include the previously-created rules file, to ensure it gets loaded on boot:
/etc/nftables.conf
include "/etc/nftables/mullvad_tailscale.conf"
It's also possible to copy the content into nftables.conf, but personally I find keeping these rules separate easier to manage and modify in future.
Now, you can enable the nftables service (and start it), and the required filewall rules will be loaded automatically on boot, without the need to manually manage them.
#What about exit nodes?
If you're reading this, I'm sure you might be thinking "but what about exit nodes?". Exit nodes let you route all traffic via another Tailnet node, achieving the same result as Mullvad. Exit nodes aren't a just a Tailscale.com feature (they work fine with Headscale), and I do have some I can use, and often do on Android for similar use-cases.
However, Mullvad has proven itself to me as being able to deal with weird network configurations, and has even bypassed a captive portal or two for me without complaint. It's because of this that, when travelling, I'd prefer to use Mullvad as the primary tunnel. Mullvad co do whatever it needs to to punch a hole out of a strange network, and Tailscale can connect me back to my devices. Sure, traffic is still going via Mullvad's servers, so there's a latency hit, but it's still secure and encrypted - and at least it works.
#Bonus - Inbound traffic
In the above examples, I've shown how to allow your device to talk to your other Tailscale devices. However, the inverse currently isn't possible. If you need another device to reach out and connect to this one, it won't be able to.
To solve this, another "chain" needs to be added to the mullvad_tailscale.conf file created earlier, to mark incoming traffic (saddr) in the same way:
tailscale-mullvad.conf
table inet mullvad_tailscale {
chain output {
type route hook output priority -100; policy accept;
ip daddr 100.64.0.0/10 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
}
chain input {
type filter hook input priority -100; policy accept;
ip saddr 100.64.0.0/10 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
}
}
Apply this in the same way, and traffic will flow 2 ways as expected.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.