--- # 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 ------------------------------------------------------------- # A peer whose public key is still a placeholder would make wg-quick # refuse the whole config, so the first run could never get far enough to # print the key you need in order to fill it in. Skip those peers instead: # the interface comes up bare, prints its key, and picks up the peer on # the next run once the key is in the vars file. - name: Select peers whose public key is a real key ansible.builtin.set_fact: wg_active_peers: >- {{ wg_peers | selectattr('public_key', 'match', '^[A-Za-z0-9+/]{43}=$') | list }} - name: Note any peers being skipped ansible.builtin.debug: msg: >- Skipping peer(s) {{ wg_peers | map(attribute='name') | difference( wg_active_peers | map(attribute='name') | list) | join(', ') }} — public key not filled in yet. The tunnel will come up with no peer. Put the key in your vars file and run again. when: wg_active_peers | length < wg_peers | length # wg-quick resolves Endpoint at startup and fails the unit if it can't, # which surfaces as an opaque systemd error. Catch it here instead. - name: Check each peer endpoint resolves ansible.builtin.command: cmd: "getent hosts {{ item.endpoint | regex_replace(':[0-9]+$', '') }}" loop: "{{ wg_active_peers | selectattr('endpoint', 'defined') | list }}" loop_control: label: "{{ item.name }}" register: endpoint_check changed_when: false failed_when: false when: item.endpoint | regex_replace(':[0-9]+$', '') is not match('^[0-9.]+$') - name: Fail clearly on an endpoint that does not resolve ansible.builtin.assert: that: item.rc == 0 fail_msg: >- Peer '{{ item.item.name }}' endpoint '{{ item.item.endpoint }}' does not resolve. Fix it in your vars file, or drop the endpoint line if this side should wait for the other to dial in. quiet: true loop: "{{ endpoint_check.results | default([]) | rejectattr('skipped', 'defined') | list }}" loop_control: label: "{{ item.item.name }}" - 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