early commit

This commit is contained in:
nick committed 2026-10-16 16:55:05 -07:00
1 parent 23cce26841
commit d36e7a484a
4 files changed
+499

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
# 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.
+48
View File
@@ -0,0 +1,48 @@
---
# ===========================================================================
# Site A. Everything that differs between sites lives in this file.
#
# ansible-playbook wg-relay.yml -e @vars/site-a.yml
#
# Site B's file is the mirror image: swap the two tunnel addresses, point
# the targets at its own service, and list Site A as the peer.
# ===========================================================================
# --- which box to configure ------------------------------------------------
# Set to the relay VM's address to configure it over SSH, or leave as
# localhost to configure the machine you're running this on.
relay_host: 192.168.1.10
relay_user: admin
# --- this side of the tunnel -----------------------------------------------
wg_address: 10.99.0.1/32
wg_listen_port: 51820
# --- the other side --------------------------------------------------------
wg_peers:
- name: site-b
public_key: "PASTE_SITE_B_PUBLIC_KEY_HERE"
# Omit endpoint on whichever side isn't reachable from outside; the
# other side dials in and keepalives hold the path open.
endpoint: site-b.example.com:51820
allowed_ips: 10.99.0.2/32
keepalive: 25
# --- what is reachable, in each direction ----------------------------------
relays:
# Site B reaching the service on Site A's LAN.
- name: in-site-b
direction: inbound
listen_address: 10.99.0.1 # this box's tunnel address
listen_port: 9000
target: 192.168.1.50:9000 # the one host:port Site B may reach
source_allow: 10.99.0.2/32 # Site B's tunnel address only
# Site A reaching the service on Site B's LAN. Local clients point at
# this box's LAN address and never need to know a tunnel exists.
- name: out-site-b
direction: outbound
listen_address: 192.168.1.10 # this box's LAN address
listen_port: 9001
target: 10.99.0.2:9000 # Site B's inbound relay
source_allow: 192.168.1.50/32 # the one local client allowed to use it
+44
View File
@@ -0,0 +1,44 @@
---
# ===========================================================================
# Site B — the mirror image of vars/site-a.yml. Run this at Site B only:
#
# ansible-playbook wg-relay.yml -e @vars/site-b.yml
#
# Note the LAN addresses here are Site B's own; they have no relationship to
# the ones in site-a.yml and the two sites may well use the same subnet.
# Only the 10.99.0.x tunnel addresses have to agree between the files.
# ===========================================================================
# --- which box to configure ------------------------------------------------
relay_host: 10.0.0.10
relay_user: admin
# --- this side of the tunnel -----------------------------------------------
wg_address: 10.99.0.2/32
wg_listen_port: 51820
# --- the other side --------------------------------------------------------
wg_peers:
- name: site-a
public_key: "PASTE_SITE_A_PUBLIC_KEY_HERE"
endpoint: site-a.example.com:51820
allowed_ips: 10.99.0.1/32
keepalive: 25
# --- what is reachable, in each direction ----------------------------------
relays:
# Site A reaching the service on Site B's LAN.
- name: in-site-a
direction: inbound
listen_address: 10.99.0.2 # this box's tunnel address
listen_port: 9000
target: 10.0.0.50:9000 # the one host:port Site A may reach
source_allow: 10.99.0.1/32 # Site A's tunnel address only
# Site B reaching the service on Site A's LAN.
- name: out-site-a
direction: outbound
listen_address: 10.0.0.10 # this box's LAN address
listen_port: 9001
target: 10.99.0.1:9000 # Site A's inbound relay
source_allow: 10.0.0.50/32 # the one local client allowed to use it
+324
View File
@@ -0,0 +1,324 @@
---
# WireGuard relay box: a tunnel to one peer, plus socat listeners that are
# the only thing reachable across it. No IP forwarding, ever.
#
# One playbook, one vars file per site. Everything that differs between
# sites lives in the vars file, including which box to configure:
#
# ansible-playbook wg-relay.yml -e @vars/site-a.yml --check --diff
# ansible-playbook wg-relay.yml -e @vars/site-a.yml
#
# No inventory file needed. Set relay_host to localhost (or leave it unset)
# to configure the machine you're running on. Defaults come from
# defaults.yml; anything in the vars file wins, since -e outranks it.
#
# The restrictions are enforced locally at each site, so neither side can
# widen the other's exposure.
- name: Resolve which box to configure
hosts: localhost
gather_facts: false
tasks:
- name: A vars file was supplied
ansible.builtin.assert:
that: relays is defined and wg_address is defined
fail_msg: >-
No site vars found. Run with -e @vars/site-a.yml (copy
vars/site-a.yml and edit it for each site).
quiet: true
- name: Target the relay named in the vars file
ansible.builtin.add_host:
name: "{{ relay_host | default('localhost') }}"
groups: wg_relay_target
ansible_user: "{{ relay_user | default(omit) }}"
ansible_connection: >-
{{ 'local'
if relay_host | default('localhost') in ['localhost', '127.0.0.1']
else 'ssh' }}
changed_when: false
- name: Configure WireGuard relay
hosts: wg_relay_target
become: true
vars_files:
- defaults.yml
handlers:
- name: Restart wireguard
ansible.builtin.systemd:
name: "wg-quick@{{ wg_interface }}"
state: restarted
listen: restart wireguard
- name: Reload nftables
ansible.builtin.command:
cmd: nft -f /etc/nftables.conf
changed_when: true
listen: reload nftables
- name: Apply sysctl
ansible.builtin.command:
cmd: sysctl --system
changed_when: true
listen: apply sysctl
tasks:
# -- preflight ----------------------------------------------------------
- name: Required variables are set
ansible.builtin.assert:
that:
- wg_address | length > 0
- wg_address is search('/32$')
- wg_peers | length > 0
- relays | length > 0
fail_msg: >-
Set wg_address (as a /32), wg_peers and relays in your site vars
file. A /32 matters: a wider mask makes wg-quick route the whole
tunnel subnet into this box.
quiet: true
- name: Peer AllowedIPs are single hosts
ansible.builtin.assert:
that: item.allowed_ips is search('/32$')
fail_msg: >-
Peer '{{ item.name }}' has AllowedIPs {{ item.allowed_ips }}.
AllowedIPs is the kernel's source filter, not just a route — widen
it and that peer can spoof any address inside the range.
quiet: true
loop: "{{ wg_peers }}"
loop_control:
label: "{{ item.name }}"
- name: Relay names are unique
ansible.builtin.assert:
that: (relays | map(attribute='name') | list | unique | length) == (relays | length)
fail_msg: "Two relays share a name; each one becomes a systemd unit."
quiet: true
- name: Install packages
ansible.builtin.package:
name:
- wireguard-tools
- socat
- nftables
state: present
# -- keys ---------------------------------------------------------------
- name: Create /etc/wireguard
ansible.builtin.file:
path: /etc/wireguard
state: directory
owner: root
group: root
mode: "0700"
- name: Look for an existing private key
ansible.builtin.stat:
path: /etc/wireguard/private.key
register: wg_privkey_stat
- name: Generate a private key on first run
ansible.builtin.shell:
cmd: set -o pipefail && umask 077 && wg genkey > /etc/wireguard/private.key
executable: /bin/bash
creates: /etc/wireguard/private.key
when:
- not wg_privkey_stat.stat.exists
- wg_private_key is not defined
- name: Read the private key back
ansible.builtin.slurp:
src: /etc/wireguard/private.key
register: wg_privkey_file
when: wg_private_key is not defined
no_log: true
- name: Settle on which private key to template
ansible.builtin.set_fact:
wg_relay_effective_private_key: >-
{{ wg_private_key if wg_private_key is defined
else (wg_privkey_file.content | b64decode | trim) }}
no_log: true
- name: Derive the public key
ansible.builtin.shell:
cmd: set -o pipefail && printf '%s' "$WGKEY" | wg pubkey
executable: /bin/bash
environment:
WGKEY: "{{ wg_relay_effective_private_key }}"
register: wg_pubkey
changed_when: false
check_mode: false
no_log: true
- name: Public key to send to the other site
ansible.builtin.debug:
msg: "{{ inventory_hostname }} public key: {{ wg_pubkey.stdout }}"
# -- no routing ---------------------------------------------------------
# Deliberately ansible.builtin only — no collections to install, so this
# playbook runs on a bare `pip install ansible-core`.
- name: Pin forwarding off at boot
ansible.builtin.copy:
dest: /etc/sysctl.d/99-wg-relay.conf
owner: root
group: root
mode: "0644"
content: |
# Managed by wg-relay.yml. This box relays, it does not route.
net.ipv4.ip_forward = 0
net.ipv6.conf.all.forwarding = 0
when: not wg_relay_forwarding_enabled
notify: apply sysctl
- name: Apply sysctl now
ansible.builtin.meta: flush_handlers
# -- tunnel -------------------------------------------------------------
- name: Write the WireGuard config
ansible.builtin.template:
src: wg0.conf.j2
dest: "/etc/wireguard/{{ wg_interface }}.conf"
owner: root
group: root
mode: "0600"
no_log: true
notify: restart wireguard
- name: Enable and start the tunnel
ansible.builtin.systemd:
name: "wg-quick@{{ wg_interface }}"
enabled: true
state: started
daemon_reload: true
# -- relays -------------------------------------------------------------
- name: Write a systemd unit per relay
ansible.builtin.template:
src: socat-relay.service.j2
dest: "/etc/systemd/system/wg-relay-{{ item.name }}.service"
owner: root
group: root
mode: "0644"
loop: "{{ relays }}"
loop_control:
label: "{{ item.name }}"
register: relay_units
- name: Find relay units we no longer define
ansible.builtin.find:
paths: /etc/systemd/system
patterns: "wg-relay-*.service"
register: existing_units
- name: Stop and remove stale relay units
block:
- name: Stop stale unit
ansible.builtin.systemd:
name: "{{ item }}"
state: stopped
enabled: false
loop: "{{ stale_units }}"
- name: Delete stale unit file
ansible.builtin.file:
path: "/etc/systemd/system/{{ item }}"
state: absent
loop: "{{ stale_units }}"
vars:
stale_units: >-
{{ existing_units.files | map(attribute='path') | map('basename') | list
| difference(relays | map(attribute='name')
| map('regex_replace', '^(.*)$', 'wg-relay-\1.service') | list) }}
when: stale_units | length > 0
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
when: relay_units is changed
- name: Enable and start each relay
ansible.builtin.systemd:
name: "wg-relay-{{ item.name }}.service"
enabled: true
state: "{{ 'restarted' if relay_units is changed else 'started' }}"
loop: "{{ relays }}"
loop_control:
label: "{{ item.name }}"
# -- firewall -----------------------------------------------------------
- name: Write the nftables ruleset
ansible.builtin.template:
src: nftables-wgrelay.conf.j2
dest: /etc/nftables.d/wgrelay.nft
owner: root
group: root
mode: "0644"
validate: "nft --check --file %s"
when: wg_relay_manage_nftables
notify: reload nftables
- name: Make sure /etc/nftables.conf includes our file
ansible.builtin.lineinfile:
path: /etc/nftables.conf
line: 'include "/etc/nftables.d/*.nft"'
create: true
owner: root
group: root
mode: "0644"
when: wg_relay_manage_nftables
notify: reload nftables
- name: Enable nftables at boot
ansible.builtin.systemd:
name: nftables
enabled: true
state: started
when: wg_relay_manage_nftables
- name: Apply pending changes now
ansible.builtin.meta: flush_handlers
# -- verify -------------------------------------------------------------
- name: Confirm forwarding is off
ansible.builtin.slurp:
src: /proc/sys/net/ipv4/ip_forward
register: fwd_check
when: not wg_relay_forwarding_enabled
- name: Fail if the kernel would route between tunnel and LAN
ansible.builtin.assert:
that: (fwd_check.content | b64decode | trim) == "0"
fail_msg: "ip_forward is on — this box would route, not relay."
quiet: true
when: not wg_relay_forwarding_enabled
- name: Confirm each relay is listening
ansible.builtin.wait_for:
host: "{{ item.listen_address }}"
port: "{{ item.listen_port }}"
timeout: 20
loop: "{{ relays }}"
loop_control:
label: "{{ item.name }}"
- name: Tunnel status
ansible.builtin.command:
cmd: wg show {{ wg_interface }}
register: wg_status
changed_when: false
check_mode: false
- name: Show tunnel status
ansible.builtin.debug:
var: wg_status.stdout_lines