diff --git a/roles/wireguard/README.md b/roles/wireguard/README.md new file mode 100644 index 0000000..62a1997 --- /dev/null +++ b/roles/wireguard/README.md @@ -0,0 +1,18 @@ +# WireGuard + +Installs WireGuard and manages `wg-quick` tunnels from `wireguard_tunnels` +(see `defaults/main.yml` for the full schema). + +- **Linux** — server (`server_mode: true`) or client. Server mode enables + masquerade + UFW rules; tunnels are managed by `wg-quick@.service`. +- **macOS (Darwin)** — client only, via Homebrew `wireguard-tools` + + `wireguard-go`. No systemd/UFW/sysctl: the config is rendered to + `{{ wireguard_config_base_path }}` (`/etc/wireguard`) and brought up with + `wg-quick`. + +## Verify + +```sh +sudo wg show # Linux +sudo /opt/homebrew/bin/wg show # macOS (or add /opt/homebrew/bin to sudo secure_path) +``` diff --git a/roles/wireguard/defaults/main.yml b/roles/wireguard/defaults/main.yml index 5d0ae0e..9c5d4d3 100644 --- a/roles/wireguard/defaults/main.yml +++ b/roles/wireguard/defaults/main.yml @@ -1,6 +1,20 @@ --- wireguard_primary_interface: "{{ network_interfaces.0.name }}" wireguard_config_base_path: /etc/wireguard +# Group owning the config dir/files. macOS has no `root` group (gid 0 is `wheel`). +wireguard_config_group: >- + {{ (ansible_facts['os_family'] == 'Darwin') | ternary('wheel', 'root') }} +# macOS: Homebrew prefix (arm64: /opt/homebrew, Intel: /usr/local). sudo's +# secure_path omits it, so wg/wg-quick must be called by absolute path. +wireguard_darwin_brew_prefix: /opt/homebrew +wireguard_wg_bin: >- + {{ (ansible_facts['os_family'] == 'Darwin') | ternary(wireguard_darwin_brew_prefix + '/bin/wg', 'wg') }} +wireguard_wg_quick_bin: >- + {{ (ansible_facts['os_family'] == 'Darwin') | ternary(wireguard_darwin_brew_prefix + '/bin/wg-quick', 'wg-quick') }} +# wg-quick's shebang is `#!/usr/bin/env bash` and requires bash 4+, but sudo's +# secure_path resolves `bash` to macOS's /bin/bash 3.2. Run it through +# Homebrew's bash explicitly (wireguard-tools depends on it). +wireguard_darwin_bash: "{{ wireguard_darwin_brew_prefix }}/bin/bash" # wireguard_tunnels: # - interface: wg0 # required: maps to wgN interface name and config filename # address: 10.0.0.1/24 # required: CIDR address for [Interface] Address @@ -8,6 +22,9 @@ wireguard_config_base_path: /etc/wireguard # dns: 10.0.0.1 # optional: DNS= line; omit to suppress # server_mode: true # optional (default: false): enables NAT masquerade + UFW rule # primary_interface: eth0 # optional: overrides wireguard_primary_interface for this tunnel +# private_key: ... # optional: use a fixed (e.g. vaulted) private key instead +# # of generating one on the host. Recommended for clients +# # whose public key is already a server's peer. # peers: # optional: list of [Peer] entries # - name: peer_name # required: comment label # public_key: ... # required: peer's public key @@ -18,7 +35,8 @@ wireguard_tunnels: [] # OS-dependent package name wireguard_package_name: >- - {{ (ansible_facts['os_family'] == 'Archlinux') | ternary('wireguard-tools', 'wireguard') }} + {{ (ansible_facts['os_family'] == 'Archlinux' or ansible_facts['os_family'] == 'Darwin') + | ternary('wireguard-tools', 'wireguard') }} # Prometheus exporter (optional, gated by wireguard_exporter_enabled). # Exposes `wg show all dump` as Prometheus metrics (bytes per peer, last diff --git a/roles/wireguard/handlers/main.yml b/roles/wireguard/handlers/main.yml index 38bf680..addbde6 100644 --- a/roles/wireguard/handlers/main.yml +++ b/roles/wireguard/handlers/main.yml @@ -2,17 +2,29 @@ - name: Reload systemd ansible.builtin.systemd: daemon_reload: true + become: true + when: ansible_facts['os_family'] != 'Darwin' - name: Apply sysctl ansible.builtin.command: sysctl --system + become: true changed_when: true + when: ansible_facts['os_family'] != 'Darwin' +# Linux applies the config live with wg syncconf (no downtime). macOS has no +# syncconf-friendly flow here, so bounce the interface through wg-quick. - name: Reload wireguard tunnel ansible.builtin.shell: | set -o pipefail - wg syncconf "{{ item.interface }}" <(wg-quick strip "{{ item.interface }}") + if [ "{{ ansible_facts['os_family'] }}" = "Darwin" ]; then + {{ wireguard_darwin_bash }} {{ wireguard_wg_quick_bin }} down "{{ item.interface }}" 2>/dev/null || true + {{ wireguard_darwin_bash }} {{ wireguard_wg_quick_bin }} up "{{ item.interface }}" + else + {{ wireguard_wg_bin }} syncconf "{{ item.interface }}" <(wg-quick strip "{{ item.interface }}") + fi args: executable: /bin/bash + become: true loop: "{{ wireguard_tunnels }}" changed_when: true @@ -20,3 +32,5 @@ ansible.builtin.systemd: name: "{{ wireguard_exporter_service }}" state: restarted + become: true + when: ansible_facts['os_family'] != 'Darwin' diff --git a/roles/wireguard/tasks/main.yml b/roles/wireguard/tasks/main.yml index 4a9fdbf..36d6280 100644 --- a/roles/wireguard/tasks/main.yml +++ b/roles/wireguard/tasks/main.yml @@ -9,10 +9,20 @@ See roles/wireguard/defaults/main.yml for configuration instructions. success_msg: "wireguard_tunnels validation passed" -- name: Install wireguard +- name: Install wireguard (Linux) ansible.builtin.package: name: "{{ wireguard_package_name }}" state: present + when: ansible_facts['os_family'] != 'Darwin' + +# Homebrew refuses to run as root, so never become for this task even when the +# role is applied with become (needed later for /etc/wireguard and wg-quick). +- name: Install wireguard (macOS) + community.general.homebrew: + name: "{{ wireguard_package_name }}" + state: present + become: false + when: ansible_facts['os_family'] == 'Darwin' - name: Install nftables (Arch server_mode PostUp dependency) ansible.builtin.package: @@ -38,13 +48,15 @@ name: systemd-resolved enabled: true state: started + when: ansible_facts['os_family'] != 'Darwin' - name: Ensure wireguard configuration is only owned by root ansible.builtin.file: path: "{{ wireguard_config_base_path }}" owner: root - group: root + group: "{{ wireguard_config_group }}" mode: "0700" + state: directory recurse: true - name: Configure tunnel diff --git a/roles/wireguard/tasks/tunnel.yml b/roles/wireguard/tasks/tunnel.yml index 340b7b5..50e4c6a 100644 --- a/roles/wireguard/tasks/tunnel.yml +++ b/roles/wireguard/tasks/tunnel.yml @@ -15,22 +15,36 @@ ansible.builtin.stat: path: "{{ wireguard_config_base_path }}/{{ _tunnel.interface }}.privatekey" register: _tunnel_pkey_file + when: _tunnel.private_key is not defined -- name: "Generate wireguard keys for {{ _tunnel.interface }} if not present" - ansible.builtin.shell: > - wg genkey | +- name: "Generate wireguard keys if not present for {{ _tunnel.interface }}" + ansible.builtin.shell: | + set -o pipefail + {{ wireguard_wg_bin }} genkey | tee {{ wireguard_config_base_path }}/{{ _tunnel.interface }}.privatekey | - wg pubkey > {{ wireguard_config_base_path }}/{{ _tunnel.interface }}.publickey - when: not _tunnel_pkey_file.stat.exists + {{ wireguard_wg_bin }} pubkey > {{ wireguard_config_base_path }}/{{ _tunnel.interface }}.publickey + args: + executable: /bin/bash + changed_when: true + when: + - _tunnel.private_key is not defined + - not _tunnel_pkey_file.stat.exists - name: "Retrieve wireguard private key for {{ _tunnel.interface }}" ansible.builtin.slurp: src: "{{ wireguard_config_base_path }}/{{ _tunnel.interface }}.privatekey" register: _tunnel_private_key_b64 + when: _tunnel.private_key is not defined -- name: "Set wireguard private key fact for {{ _tunnel.interface }}" +- name: "Set wireguard private key fact from inventory for {{ _tunnel.interface }}" + ansible.builtin.set_fact: + _tunnel_private_key: "{{ _tunnel.private_key }}" + when: _tunnel.private_key is defined + +- name: "Set wireguard private key fact from host for {{ _tunnel.interface }}" ansible.builtin.set_fact: _tunnel_private_key: "{{ _tunnel_private_key_b64['content'] | b64decode }}" + when: _tunnel.private_key is not defined - name: "Resolve effective DNS for {{ _tunnel.interface }}" ansible.builtin.set_fact: @@ -41,7 +55,7 @@ src: wireguard.conf.j2 dest: "{{ wireguard_config_base_path }}/{{ _tunnel.interface }}.conf" owner: root - group: root + group: "{{ wireguard_config_group }}" mode: "0600" notify: Reload wireguard tunnel @@ -50,6 +64,7 @@ path: "/etc/systemd/system/wg-quick@{{ _tunnel.interface }}.service.d" state: directory mode: "0755" + when: ansible_facts['os_family'] != 'Darwin' - name: "Deploy systemd override for network dependency for {{ _tunnel.interface }}" ansible.builtin.template: @@ -57,6 +72,7 @@ dest: "/etc/systemd/system/wg-quick@{{ _tunnel.interface }}.service.d/network-dependency.conf" mode: "0644" notify: Reload systemd + when: ansible_facts['os_family'] != 'Darwin' - name: "Enable IP forwarding for {{ _tunnel.interface }}" ansible.builtin.copy: @@ -64,10 +80,12 @@ content: | net.ipv4.ip_forward = 1 owner: root - group: root + group: "{{ wireguard_config_group }}" mode: "0644" notify: Apply sysctl - when: _tunnel.server_mode | default(false) + when: + - _tunnel.server_mode | default(false) + - ansible_facts['os_family'] != 'Darwin' - name: "Configure the firewall for {{ _tunnel.interface }}" community.general.ufw: @@ -83,6 +101,7 @@ when: - _tunnel.server_mode | default(false) - _tunnel.port is defined + - ansible_facts['os_family'] != 'Darwin' - name: "Allow forwarding for {{ _tunnel.interface }}" community.general.ufw: @@ -100,7 +119,9 @@ out: "{{ _tunnel.primary_interface | default(wireguard_primary_interface) }}" - in: "{{ _tunnel.primary_interface | default(wireguard_primary_interface) }}" out: "{{ _tunnel.interface }}" - when: _tunnel.server_mode | default(false) + when: + - _tunnel.server_mode | default(false) + - ansible_facts['os_family'] != 'Darwin' - name: "Start and enable wg-quick@{{ _tunnel.interface }}" ansible.builtin.service: @@ -108,3 +129,19 @@ state: started enabled: true daemon_reload: true + when: ansible_facts['os_family'] != 'Darwin' + +# macOS: no systemd; wg-quick drives wireguard-go directly. Idempotent via the +# list of active interfaces rather than a `creates` path. +- name: "List active wireguard interfaces (macOS)" + ansible.builtin.command: "{{ wireguard_wg_bin }} show interfaces" + register: _wg_active_interfaces + changed_when: false + when: ansible_facts['os_family'] == 'Darwin' + +- name: "Bring up tunnel (macOS) for {{ _tunnel.interface }}" + ansible.builtin.command: "{{ wireguard_darwin_bash }} {{ wireguard_wg_quick_bin }} up {{ _tunnel.interface }}" + changed_when: true + when: + - ansible_facts['os_family'] == 'Darwin' + - _tunnel.interface not in _wg_active_interfaces.stdout.split()