Mullvad + Tailscale
Update
While writing this I found that Tailscale and Mullvad had a secret partnership that they announced today.
I may update this a bit more, but I am going to be tracking a different way to do this with headscale. Some nascent tracking is in an issue.
Using Mullvad and Tailscale
As I have been working in a public library recently, I thought a VPN was warranted and bought a Mullvad gift card off Amazon1.
When I activated my Mullvad VPN, Tailscale was no longer getting traffic. I realized that Mullvad had configured nftables to forward all traffic to it’s interface (and therefore bypassed Tailscale’s interface)2.
If you are just looking for a solution please just apply this nftables configuration3.
table inet mullvad-tailscale {
chain prerouting {
type filter hook prerouting priority -100;
policy accept;
ip saddr 100.64.0.0/10 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
}
chain outgoing {
type route hook output priority -100;
policy accept;
meta mark 0x80000 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
ip daddr 100.64.0.0/10 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
}
}
I will take the rest of this blog post to do a small dive into nft and how mullvad split-tunnel works.
The players
Player | Description | Importance |
---|---|---|
Wireguard | WireGuard® is an extremely simple yet fast and modern VPN that utilizes state-of-the-art cryptography.4 | How both Mullvad and Tailscale creates tunnels to other machines5 |
NFT6 | nftables is the modern Linux kernel packet classification framework.7 | How packet routing is done by the mullvad daemon |
Mullvad | a VPN service | Creates the packet routing configuration |
Tailscale | a way to create peer-to-peer connections other machines (regardless of where they are)8 | Is not getting packets |
The problem
When the mullvad daemon comes up, nftables is then configured to route all of the packets through the new wireguard tunnel that is created (which shows up in the form of an interface).
This nft configuration is why none of the packets are being routed to the tailscale wireguard tunnel anymore.
The solutions
An simple unsatisfactory solution
Let’s just forward traffic for tailscale ips to the tailscale. It’s stupid, it’s simple, it should work.9
nft insert rule inet mullvad output oifname "tailscale*" accept
nft insert rule inet mullvad input iifname "tailscale*" accept
However, this completely skips the mullvad daemon, which is somewhat nonideal.
A better solution
Can we do something with mullvad’s split tunnel?
Let’s follow how it is implemented in the linux app
Meta Mark
// mullvad-daemon/src/main.rs:40
fn main() {
// ...
let exit_code = match runtime.block_on(run_platform(config, log_dir)) {
Ok(_) => 0,
Err(error) => {
log::error!("{}", error);
1
}
};
// ...
}
// mullvad-daemon/src/main.rs:128
#[cfg(target_os = "linux")]
async fn run_platform(config: &cli::Config, log_dir: Option<PathBuf>) -> Result<(), String> {
if config.initialize_firewall_and_exit {
return crate::early_boot_firewall::initialize_firewall()
.await
.map_err(|err| format!("{err}"));
}
run_standalone(log_dir).await
}
// mullvad-daemon/src/early_boot_firewall.rs:17
pub async fn initialize_firewall() -> Result<(), Error> {
let mut firewall = Firewall::new(mullvad_types::TUNNEL_FWMARK)?;
// ...
}
// mullvad-types/src/lib.rs:23
#[cfg(target_os = "linux")]
pub const TUNNEL_FWMARK: u32 = 0x6d6f6c65;
Connection Tracking Mark
// mullvad-daemon/src/main.rs:40
fn main() {
// ...
let exit_code = match runtime.block_on(run_platform(config, log_dir)) {
Ok(_) => 0,
Err(error) => {
log::error!("{}", error);
1
}
};
// ...
}
// mullvad-daemon/src/main.rs:128
#[cfg(target_os = "linux")]
async fn run_platform(config: &cli::Config, log_dir: Option<PathBuf>) -> Result<(), String> {
if config.initialize_firewall_and_exit {
return crate::early_boot_firewall::initialize_firewall()
.await
.map_err(|err| format!("{err}"));
}
run_standalone(log_dir).await
}
// mullvad-daemon/src/early_boot_firewall.rs:17
pub async fn initialize_firewall() -> Result<(), Error> {
let mut firewall = Firewall::new(mullvad_types::TUNNEL_FWMARK)?;
// ...
}
// talpid-core/src/firewall/mod.rs:241
pub struct Firewall {
inner: imp::Firewall,
}
// talpid-core/src/firewall/linux.rs:288
// ...
pub fn finalize(mut self, policy: &FirewallPolicy, fwmark: u32) -> Result<FinalizedBatch> {
self.add_loopback_rules()?;
self.add_split_tunneling_rules(policy, fwmark)?;
self.add_dhcp_client_rules();
// ...
}
// ...
// talpid-core/src/firewall/linux.rs:296
// ...
fn add_split_tunneling_rules(&mut self, policy: &FirewallPolicy, fwmark: u32) -> Result<()> {
// ...
rule.add_expr(&nft_expr!(meta cgroup));
rule.add_expr(&nft_expr!(cmp == split_tunnel::NET_CLS_CLASSID));
rule.add_expr(&nft_expr!(immediate data split_tunnel::MARK));
// ...
// ...
// talpid-core/src/split_tunnel/linux.rs:16
/// Value used to mark packets and associated connections.
/// This should be an arbitrary but unique integer.
pub const MARK: i32 = 0xf41;
What we find is that there are two marks. One that decides to allow the incoming traffic, and one that puts the traffic outside of the firewall.
-
Mullvad allows you to buy a gift card from amazon, which I think is one of the most secure way to avoid linking your personal info with your mullvad number. ↩︎
-
This was one of the hardest bugs I have had to find. ↩︎
-
sudo nft -f file.nft
↩︎ -
From the wireguard website ↩︎
-
One of the killer features of wireguard is that the tunnel presents as a interface, and you can interact with it as such. This is because it is implemented at a kernel level. ↩︎
-
(It’s not the blockchain thing) ↩︎
-
From the nftables wiki ↩︎
-
This is done through a public control server, where the keys are distributed from. Then through some cool STUN and DERP stuff, connections are created. There is even more cool stuff as well ↩︎
-
Thanks Rakesh Sasidharan ↩︎