322 lines
9.9 KiB
YAML
322 lines
9.9 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.
|
|
# The restrictions are enforced locally at each site.
|
|
|
|
- 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 |