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:
- a WireGuard tunnel to Mullvad that installs no route;
- Transmission bound to the tunnel addresses;
pfto route the Transmission packets into the tunnel, and to drop them if they try to leave any other way.
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.