My New Hugo Site

OpenVPN multihome — VPN Everywhere Except Home

My home VPN had exactly one dead zone: home. From a café, from the office, from cellular — it connects. From my own couch — timeout. This bugged me for a long time, and last night a one-line config change finally killed it.

The setup

The VPN server is my OpenWrt router, running OpenVPN 2.6.14 on udp/1194. Its WAN address comes from DHCP, so clients connect by DDNS name, vpn.example.com. Every client profile just says remote vpn.example.com 1194 and works from anywhere.

                     internet
                         │
               vpn.example.com (DDNS)
                   → 203.0.113.7
                         │
┌────────────────────────────────────────────┐
│ router (OpenWrt)  OpenVPN 2.6.14, udp/1194 │
│                                            │
│   wan ─────────────── 203.0.113.7          │
│   lan ─────────────── 192.168.1.1/24       │
│   tun ─────────────── 10.8.0.1/24          │
└────────────────────────────────────────────┘
                         │ lan
         ┌───────────────┼───────────────┐
       laptop           phone          tablet

The symptom

From outside, my laptop connects in about a second. From the LAN, the same profile, the same command, the same DNS name — the TLS handshake just never completes. The server sees the client’s packets; the client never sees a reply it accepts. No error, no log line pointing at the cause. Classic “works everywhere except where I actually sit” bug.

What is actually happening

DNS doesn’t know you’re at home. vpn.example.com resolves to the WAN IP everywhere, so the laptop sends its handshake to 203.0.113.7:1194 even though it’s two meters from the router. The packet never leaves the router — its destination is one of its own addresses — but that’s fine, OpenVPN listens on 0.0.0.0, so it receives it.

The reply is where it falls apart. Without any special handling, the kernel picks the reply’s source address from the routing table: the laptop is on the LAN, so the “best” source is 192.168.1.1.

 laptop (192.168.1.100)                         router
 ────────────────────────────►   udp  src 192.168.1.100:52114
                                      dst 203.0.113.7:1194

                      OpenVPN replies:
                      routing table says "peer is on lan"
                      → source becomes 192.168.1.1

 ◄────────────────────────────   udp  src 192.168.1.1:1194
                                       dst 192.168.1.100:52114

 laptop:  "I was talking to 203.0.113.7.
           who is 192.168.1.1?"       → drop.  handshake
                                         never finishes.

A UDP “connection” is really just the client remembering which address it dialed, and ignoring datagrams from anyone else. The server answered from a different face of itself, so as far as the client is concerned, nobody ever answered.

And here’s why this bug is so good at hiding: for a client on the internet, the routing table picks the WAN address as reply source — which happens to be the address the client dialed. Reply source matches, everything works. The mismatch only exists for clients reaching the WAN address from the inside. The network worked exactly when I was far away from it.

The fix: one line

# /etc/openvpn/server.conf on the router
proto udp
port 1194
multihome        # ← this one

From the man page:

Configure a multi-homed UDP server. […] This option will add some extra lookups to the packet path to ensure that the UDP reply packets are always sent from the address that the client is talking to.

Mechanically, this is IP_PKTINFO. The kernel now tells OpenVPN the destination address of every incoming datagram, and OpenVPN pins that same address as the source of its reply:

 ────────────────────────────►   dst 203.0.113.7:1194
                     recvmsg(IP_PKTINFO):
                     "this packet was addressed to 203.0.113.7"

 ◄────────────────────────────   src 203.0.113.7:1194  ✓
                     sendmsg(IP_PKTINFO):
                     "reply from 203.0.113.7"

Now the reply comes from exactly the address the client dialed, whether that client is on the internet or on my couch. Fun detail: run openvpn --version and the build flags include [MH/PKTINFO] — the support was sitting there the whole time, waiting for one config line.

Why I didn’t do the usual workarounds

The common fixes for this are all client-side or DNS-side: split-horizon DNS (answer the DDNS name with the LAN IP inside), or hairpin NAT rules on the router. They work, but they add a second code path that only exists at home, and every roaming device silently depends on it.

multihome is one server-side line, and the same .ovpn profile behaves identically on my LAN and on the road. Two footnotes from the man page: it’s UDP-only (fine — UDP is what you want anyway), and it adds a little per-packet processing (irrelevant at home-router scale). It also composes nicely with a DHCP WAN: I can’t pin the server to the WAN address with local when that address keeps changing, but a wildcard bind plus multihome gives me correct behavior for every address the router owns.

Applied it to the router at my other site too, for the same reason.

I found this while rotating per-device client certificates — testing each client as I went is exactly what forces you to connect from the LAN, which is exactly where this bug lives. A bug that only appears when you’re closest to the machine. That’s my kind of reminder that the network doesn’t care about physical distance, only about addresses.