# 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. ```bash 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.