wg-relay
A WireGuard tunnel between two sites where the only thing reachable across it is one TCP port on one host per side.
ansible/
├── inventory.ini
├── wg-relay.yml # the whole thing
├── group_vars/all.yml # defaults
├── host_vars/site-a.yml # Site A (Site B writes the mirror image)
└── templates/
├── wg0.conf.j2
├── socat-relay.service.j2
└── nftables-wgrelay.conf.j2
Needs ansible-core and nothing else — no collections to install.
How it works
The relay VM never routes. ip_forward is pinned to 0 and the nftables
forward chain policy is drop, so there is no path from the tunnel to the
LAN. The only things listening are socat processes you declared:
- an inbound relay bound to the tunnel address, forwarding to one
host:porton the local LAN - an outbound relay bound to the LAN address, forwarding down the tunnel
Local clients point at the relay VM's LAN address and never learn a tunnel exists, so nothing on them needs reconfiguring if the relay changes.
Three layers have to agree before a packet gets anywhere:
- WireGuard —
AllowedIPsis a/32, which the kernel enforces as a source filter. A peer that forges a source address outside its own/32is dropped before any userspace sees it. - nftables — the tunnel interface ends in an explicit
drop; only the declared relay ports, from the declared source, get through. - socat — forwards to exactly one
host:port, running asnobody.
Each side's restrictions live entirely on its own machine. There is no mechanism for one site to prove to the other how its half is configured — and none is needed, since the half that protects you is the half you control.
First run
- Run it on each side with the peer key left as the placeholder. It generates a keypair and prints the public key.
- Exchange public keys, fill in
wg_peers[].public_key, run again. - Point the local client at the relay VM's LAN address and port.
ansible-playbook -i inventory.ini wg-relay.yml --check --diff
ansible-playbook -i inventory.ini wg-relay.yml
The playbook ends by asserting forwarding is off, waiting for each relay port
to accept connections, and printing wg show.
Notes
- The
wg0.conftask isno_log: truebecause the file contains the private key;--diffwon't show it changing. That's deliberate. - Keys live in
/etc/wireguard/private.key(0600, root-only). To manage them centrally instead, setwg_private_keyfrom Ansible Vault and the playbook skips generation. - Removing a relay from
host_varsstops and deletes its systemd unit on the next run — no orphaned listeners. - If either uplink is PPPoE or tunnelled, drop
wg_mtuto 1380. Sustained bulk transfers are what find PMTU blackholes, and the symptom is a stall mid-transfer rather than a clean failure. socat_group: nogroupis Debian/Ubuntu. On RHEL-family set it tonobody.- Whichever side is behind CGNAT should omit
endpointand let the other side dial in;PersistentKeepaliveholds the path open. If both sides are behind CGNAT, neither can reach the other directly and this design doesn't apply as-is. - The relay is TCP only. A UDP service needs
UDP-LISTEN/UDPin the socat unit and a matchingudp dportrule in the nftables template. - Protocols that embed their own hostname or redirect clients to a second address (anything doing virtual-host-style routing) need that address to resolve on the client side too — a relay changes the address clients connect to, and some protocols notice.