Files
wg-backup/wg-relay.yml
T
2026-10-16 16:55:05 -07:00

324 lines
10 KiB
YAML

---
# 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