A homelab mesh over AmneziaWG
My routers live in several places: a few hubs on rented servers in different countries and a handful of sites — home, the homelab rack and a couple of others. They are all OpenWrt, and they are tied together with AmneziaWG, a WireGuard fork whose packets don’t look like WireGuard on the wire. This note is about the routing on top, not about the tunnels themselves.
Shape
- One tunnel per link. Every link is its own point-to-point interface with exactly one peer: the hubs are joined into a mesh, every site connects to every hub. More interfaces, but each one is simple, has its own counters, and can be restarted on its own.
- IPv6 inside the tunnels. Each link gets a
/127plus a link-local address. Every router has a loopback address that the rest of the network uses to reach it. Loopbacks live on Linux dummy interfaces, which stock OpenWrt can’t declare in/etc/config/network, so I packaged a small netifd protocol for them — netifd-proto-dummy, with a LuCI page in luci-proto-dummy. - Everything is in Ansible. Interfaces, keys, firewall zones and routing
are generated from inventory with
community.openwrt.
The tunnels themselves — keys, shared PSKs, peers, cleanup — are handled by
the
wg_interfaceandwg_peermodules I wrote for exactly this mesh (#281), and the rest goes through batcheducioperations — see the ucode note.
Routing
One BIRD daemon per router:
- OSPFv3 is the underlay: it finds paths between loopbacks across the tunnels, keeps equal-cost paths, and gives each direction of a link its own cost.
- BGP is the overlay: it carries the actual prefixes — site networks, service addresses — between loopbacks, with next hops resolved through OSPF.
- No BFD. A tuned OSPF dead interval of a few seconds is enough for hard failures.
Two extra pieces make it pleasant to use:
- Sticky return path. With several hubs a reply can try to leave through a different tunnel than the request came in on, and stateful firewalls hate that. vpn-sticky marks each connection with its ingress tunnel (an nftables conntrack mark), and replies are routed back through a per-tunnel routing table. The nftables rules are rendered from UCI by a ucode template.
- Anycast services. Hubs announce shared service addresses (DNS, for example) into BGP only while a local health check passes, so clients always reach a working one.
A netifd quirk
A link-local routing protocol needs an fe80::/64 route on every tunnel.
OpenWrt’s netifd only adds one when the interface has a non-zero metric, and
Linux keeps a single fe80::/64 per metric value — so tunnels that share a
metric silently lose their link-local routes, except for the one brought up
last. The fix is boring: give every tunnel its own metric.
What didn’t work
Part of the motivation was to detect throttled cross-border links and route around them automatically. I tried two quality-aware protocols:
- Babel (standalone babeld, then FRR’s): neighbours formed, round-trip times showed the slow links nicely, but routes never converged across the mesh within the test window.
- BMX7 formed no neighbours at all. It refuses interfaces without a
global IPv6 address (a
/127doesn’t count), and its discovery starts with multicast, which WireGuard-family tunnels don’t carry.
Both also share a deeper problem: their link quality comes from small hello packets. A throttled link often passes pings and hellos perfectly while bulk TCP crawls, so a hello-based metric calls it healthy.
All three packages live in my OpenWrt feed, my-openwrt-feed, together with the rest of what these routers run.
Next
An external controller that measures each link for real — throughput, not just reachability — and feeds the result into OSPF costs, with enough hysteresis that routes don’t flap.