# 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/ ├── wg-relay.yml # the whole thing ├── defaults.yml # defaults ├── vars/ │ ├── site-a.yml # Site A (Site B writes the mirror image) │ └── site-b.yml └── templates/ ├── wg0.conf.j2 ├── socat-relay.service.j2 └── nftables-wgrelay.conf.j2 ``` ## 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. ## Prerequisites You can use the ansible repositories or another installation method if desired, all that matters is you have ansible Needs `ansible-core`, no other packages or collections are needed. The playbook will install additional required packages ``` sudo apt update && sudo apt install ansible ``` ## Setup The playbook and `defaults.yml` are identical at both sites. Everything that differs lives in that site's vars file — copy `vars/site-a.yml` and edit: - `relay_host` / `relay_user` — which box to configure. Set it to the relay VM to run over SSH, or leave it as `localhost` to configure the machine you're sitting on. - `wg_address` — this side's tunnel address, as a `/32`. - `wg_peers` — the other site's public key, endpoint and `/32`. - `relays` — the inbound and outbound listeners. Values in the vars file override `defaults.yml`, because `-e` outranks everything else. Adding a site later means one more file in `vars/`; the playbook doesn't change. ## Installation Run it at each site against that site's vars file. No inventory file needed. ``` ansible-playbook wg-relay.yml -e @vars/site-a.yml --check --diff ansible-playbook wg-relay.yml -e @vars/site-a.yml ``` Leave the peer's public key as the placeholder on the first run — it generates a keypair and prints this side's public key. Exchange public keys, fill in `wg_peers[].public_key` in each vars file, then run again. Finally, point the local client at the relay VM's LAN address and port. Each run 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 the vars file 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.