2026-10-16 16:55:05 -07:00
2026-10-16 16:55:05 -07:00
2026-10-16 16:55:05 -07:00
2026-10-16 16:55:05 -07:00
2026-10-16 16:55:05 -07:00

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:port on 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:

  1. WireGuard — AllowedIPs is a /32, which the kernel enforces as a source filter. A peer that forges a source address outside its own /32 is dropped before any userspace sees it.
  2. nftables — the tunnel interface ends in an explicit drop; only the declared relay ports, from the declared source, get through.
  3. socat — forwards to exactly one host:port, running as nobody.

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

  1. Run it on each side with the peer key left as the placeholder. It generates a keypair and prints the public key.
  2. Exchange public keys, fill in wg_peers[].public_key, run again.
  3. 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.conf task is no_log: true because the file contains the private key; --diff won't show it changing. That's deliberate.
  • Keys live in /etc/wireguard/private.key (0600, root-only). To manage them centrally instead, set wg_private_key from Ansible Vault and the playbook skips generation.
  • Removing a relay from host_vars stops and deletes its systemd unit on the next run — no orphaned listeners.
  • If either uplink is PPPoE or tunnelled, drop wg_mtu to 1380. Sustained bulk transfers are what find PMTU blackholes, and the symptom is a stall mid-transfer rather than a clean failure.
  • socat_group: nogroup is Debian/Ubuntu. On RHEL-family set it to nobody.
  • Whichever side is behind CGNAT should omit endpoint and let the other side dial in; PersistentKeepalive holds 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/UDP in the socat unit and a matching udp dport rule 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.
S
Description
No description provided
Readme
50 KiB
0 Stars 1 Watchers 0 Forks
Languages
Jinja 100%