Torrents through Mullvad on FreeBSD

I run Transmission on FreeBSD. For privacy reasons, I do not want it to announce one of my public IP addresses to every peer of every torrent.

The usual answer is to put the whole machine behind a VPN. I cannot do that: the other services of the server must answer on its public addresses. So only the peer-to-peer traffic of Transmission goes through Mullvad, over WireGuard, and everything else keeps the normal route.

The setup has three parts:

Below is the high-level setup. I set up two WireGuard connections: one, wg0, to reach the Transmission web interface, and one, wg1, to go out to Mullvad.

      laptop, phone
            |
            | wg0, web interface
            v
+------------------------------+
| server                       |
|                              |
|   Transmission               |
|        |                     |
|        +-- wg1 -----------------> Mullvad --> peers
|        |                     |
|        +-- ixl0: dropped     |
|                              |
|   other services -- ixl0 -------> Internet
+------------------------------+

The tunnel

The wireguard-tools rc script starts both interfaces:

sysrc wireguard_enable=YES
sysrc wireguard_interfaces="wg0 wg1"

The configuration comes from the Mullvad configuration generator. The diff shows my changes:

 [Interface]
 PrivateKey = ...
 Address = 10.72.112.66/32,fc00:bbbb:bbbb:bb01::9:7041/128
-DNS = 10.64.0.1
+Table = off
+MTU = 1420
+PostUp = /usr/sbin/service transmission restart
+PostDown = /usr/sbin/service transmission stop

 [Peer]
 PublicKey = ...
 AllowedIPs = 0.0.0.0/0,::0/0
 Endpoint = ...:51820

The DNS line would make wg-quick replace the resolver of the whole machine with the Mullvad one, so I removed it.

AllowedIPs covers the whole Internet, so wg-quick would normally replace the default route and send all the traffic of the machine to Mullvad. Table = off tells it not to touch the routing table: the interface comes up with its addresses, and no packet uses it unless something sends it there.

PostUp and PostDown tie Transmission to the tunnel. The Mullvad addresses exist only while wg1 is up, so Transmission starts after the tunnel and stops with it.

Transmission

Transmission binds its peer sockets to the tunnel addresses:

"bind-address-ipv4": "10.72.112.66",
"bind-address-ipv6": "fc00:bbbb:bbbb:bb01::9:7041",

Binding sets the source address and nothing else; the routing table still sends the packets out the default route on ixl0, so pf has to push them into the tunnel.

A few other settings matter here:

"encryption": 2,
"lpd-enabled": false,
"port-forwarding-enabled": false,

encryption: 2 only accepts encrypted peers. Local peer discovery is off: there is no local network worth announcing to on a server in a data center. Port forwarding (UPnP and NAT-PMP) is off as well, since there is no router to ask for a port.

Mullvad removed port forwarding in 2023, so peers cannot connect to me. Transmission connects to them instead. The only peers it misses are the ones that, like me, cannot accept incoming connections.

Routing with pf

The first pf rule sends every packet with a Mullvad source address into wg1, whatever the routing table says:

mullvad_v4 = "10.72.112.66"
mullvad_v6 = "fc00:bbbb:bbbb:bb01::9:7041"

pass out quick route-to (wg1 10.64.0.1) \
    inet from $mullvad_v4 to any keep state
pass out quick route-to (wg1 fc00:bbbb:bbbb:bb01::1) \
    inet6 from $mullvad_v6 to any keep state

10.64.0.1 and fc00:bbbb:bbbb:bb01::1 are the Mullvad gateways inside the tunnel. This is policy routing, where the source address of a packet decides where it goes, not just its destination.

The second rule is the kill switch:

block drop out quick on $ext_if proto { tcp udp } user transmission

Any TCP or UDP packet of the transmission user that reaches ixl0 is dropped. If the tunnel is down, or if Transmission one day ignores its bind address, nothing leaks with the public address.

Both rules are quick, so the first match wins and the order of the rules matters. The route-to rules come first and divert the Mullvad packets before the kill switch can see them on ixl0. In the other order, the kill switch drops everything, tunnel included.

I learned the proto { tcp udp } part the hard way. user only matches TCP and UDP sockets, and my first version of the rule had no proto. It dropped every ICMP packet of the host: ping and ping6 stopped working on the whole machine, not only for Transmission.

What still leaks: DNS

Transmission resolves tracker hostnames through the system resolver, a local unbound. unbound runs as its own user and sends its queries from the public address, so the kill switch does not see them.

unbound is a full recursive resolver: it asks the authoritative servers directly. Query-name minimisation, on by default in recent versions, keeps the root and TLD servers from seeing the full name. But the authoritative nameservers of the tracker domain do see my public address ask for the tracker hostname. Those servers are often run by the tracker operator or its CDN. The peers never learn my address, but the people running the tracker can.

The web interface

The Transmission web interface listens on 127.0.0.1:9091, and a reverse proxy exposes it on wg0.

Transmission also checks the client address and the Host header:

"rpc-bind-address": "127.0.0.1",
"rpc-host-whitelist": "...",
"rpc-host-whitelist-enabled": true,
"rpc-whitelist": "127.0.0.1",
"rpc-whitelist-enabled": true,

On wg0, pf lets my devices reach SSH, HTTPS and DNS on the server, nothing more:

pass in quick on wg0 proto tcp from 172.16.0.0/24 to 172.16.0.1 port { 22 443 } \
    keep state
pass in quick on wg0 proto { tcp udp } from 172.16.0.0/24 to 172.16.0.1 port 53 \
    keep state

So from my laptop or phone, I bring up wg0 and open the web interface in a browser. From the public Internet, port 9091 doesn’t exist.