From 909bef2d5056ee3d9bd5a01fd79e870f86b50901 Mon Sep 17 00:00:00 2001 From: 0xafrogeek Date: Tue, 31 Mar 2026 01:50:43 +0000 Subject: [PATCH 01/14] feat: add support for wireguard vpn --- deploy/dhis2.yml | 4 + deploy/inventory/group_vars/all.template | 16 ++- deploy/inventory/hosts.template | 6 + deploy/roles/wireguard/defaults/main.yml | 33 +++++ deploy/roles/wireguard/handlers/main.yml | 42 ++++++ deploy/roles/wireguard/meta/main.yml | 18 +++ deploy/roles/wireguard/tasks/firewall.yml | 25 ++++ .../wireguard/tasks/lockdown_instances.yml | 29 ++++ .../wireguard/tasks/lockdown_monitor.yml | 98 +++++++++++++ .../wireguard/tasks/lockdown_postgres.yml | 24 ++++ .../roles/wireguard/tasks/lockdown_proxy.yml | 42 ++++++ deploy/roles/wireguard/tasks/main.yml | 41 ++++++ deploy/roles/wireguard/tasks/server.yml | 131 ++++++++++++++++++ .../roles/wireguard/templates/client.conf.j2 | 26 ++++ deploy/roles/wireguard/templates/wg0.conf.j2 | 36 +++++ 15 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 deploy/roles/wireguard/defaults/main.yml create mode 100644 deploy/roles/wireguard/handlers/main.yml create mode 100644 deploy/roles/wireguard/meta/main.yml create mode 100644 deploy/roles/wireguard/tasks/firewall.yml create mode 100644 deploy/roles/wireguard/tasks/lockdown_instances.yml create mode 100644 deploy/roles/wireguard/tasks/lockdown_monitor.yml create mode 100644 deploy/roles/wireguard/tasks/lockdown_postgres.yml create mode 100644 deploy/roles/wireguard/tasks/lockdown_proxy.yml create mode 100644 deploy/roles/wireguard/tasks/main.yml create mode 100644 deploy/roles/wireguard/tasks/server.yml create mode 100644 deploy/roles/wireguard/templates/client.conf.j2 create mode 100644 deploy/roles/wireguard/templates/wg0.conf.j2 diff --git a/deploy/dhis2.yml b/deploy/dhis2.yml index ec4b36a..669cc1b 100644 --- a/deploy/dhis2.yml +++ b/deploy/dhis2.yml @@ -5,6 +5,8 @@ gather_facts: false roles: - role: pre-install + - role: wireguard + tags: [wireguard] - name: DHIS2 setup gather_facts: false @@ -20,6 +22,8 @@ tags: [proxy-install] - role: monitoring tags: monitoring + - role: wireguard + tags: [wireguard] - role: create-instance tags: [create-instance] when: instance_state is not defined or instance_state != 'deleted' diff --git a/deploy/inventory/group_vars/all.template b/deploy/inventory/group_vars/all.template index eaf4d13..8e2befa 100644 --- a/deploy/inventory/group_vars/all.template +++ b/deploy/inventory/group_vars/all.template @@ -1,2 +1,16 @@ --- -# This is just template. Ansible hosts are members of groups, in our tools we have 3 most commong groups, [web], [databases], [monitoring] +# This is just template. Ansible hosts are members of groups, in our tools we have 3 most commong groups, [web], [databases], [monitoring] + +# WireGuard VPN peers — one entry per VPN peer. +# Generate keys on the admin workstation: +# wg genkey | tee private.key | wg pubkey > public.key +# wg genpsk > preshared.key # optional, post-quantum resistance +# +# public_key and preshared_key should be vault-encrypted: +# ansible-vault encrypt_string '' --name '' +# +# wireguard_peers: +# - name: admin-alice +# public_key: "abc123...=" +# allowed_ips: "10.8.0.2/32" +# preshared_key: "xyz789...=" # optional — vault-encrypt if using it diff --git a/deploy/inventory/hosts.template b/deploy/inventory/hosts.template index 4ea0893..c30f0e1 100644 --- a/deploy/inventory/hosts.template +++ b/deploy/inventory/hosts.template @@ -50,6 +50,12 @@ postgresql_version=16 server_monitoring=munin app_monitoring=glowroot +# WireGuard VPN — restricts Grafana, Prometheus, Munin, and PostgreSQL to VPN-only access. +# Define peers in group_vars/all.yml (see group_vars/all.template). +wireguard_enabled=false +# wireguard_lockdown_monitoring=true +# wireguard_port=51820 + # lxd lxd_network=172.19.2.1/24 diff --git a/deploy/roles/wireguard/defaults/main.yml b/deploy/roles/wireguard/defaults/main.yml new file mode 100644 index 0000000..b66d56c --- /dev/null +++ b/deploy/roles/wireguard/defaults/main.yml @@ -0,0 +1,33 @@ +--- +wireguard_enabled: false + +# WireGuard VPN network +wireguard_network: '10.8.0.0/24' +wireguard_server_ip: '10.8.0.1' +wireguard_port: 51820 +wireguard_interface: wg0 + +lxd_bridge_interface: 'lxdbr1' + +lxd_network: '172.19.2.0/24' + +# LXD gateway IP — the host-side IP of the LXD bridge. +# After MASQUERADE (wg0.conf.j2 PostUp), container UFW sees traffic sourced +# from this IP, NOT from wireguard_network (10.8.0.x). +# Verify with: lxc network show lxdbr1 | grep ipv4.address +lxd_gateway_ip: '172.19.2.1' + +# VPN peers (clients) +# Generate keys on the admin workstation: +# wg genkey | tee private.key | wg pubkey > public.key +# wg genpsk > preshared.key (optional, post-quantum) +# +# Example: +# wireguard_peers: +# - name: admin-alice +# public_key: "abc123...=" +# allowed_ips: "10.8.0.2/32" +# preshared_key: "xyz789...=" # optional +wireguard_peers: [] + +wireguard_lockdown_monitoring: true diff --git a/deploy/roles/wireguard/handlers/main.yml b/deploy/roles/wireguard/handlers/main.yml new file mode 100644 index 0000000..483d52b --- /dev/null +++ b/deploy/roles/wireguard/handlers/main.yml @@ -0,0 +1,42 @@ +--- +- name: Restart WireGuard + ansible.builtin.systemd: + name: 'wg-quick@{{ wireguard_interface }}' + state: restarted + daemon_reload: true + +- name: Sync WireGuard peers + ansible.builtin.shell: | + wg syncconf {{ wireguard_interface }} <(wg-quick strip /etc/wireguard/{{ wireguard_interface }}.conf) + args: + executable: /bin/bash + changed_when: true + +- name: Reload UFW + community.general.ufw: + state: reloaded + +- name: Reload Nginx + ansible.builtin.service: + name: nginx + state: reloaded + +- name: Reload Apache2 + ansible.builtin.service: + name: apache2 + state: reloaded + +- name: Restart Grafana + ansible.builtin.service: + name: grafana-server + state: restarted + +- name: Restart Apache2 + ansible.builtin.service: + name: apache2 + state: restarted + +- name: Reload PostgreSQL + ansible.builtin.service: + name: postgresql + state: reloaded diff --git a/deploy/roles/wireguard/meta/main.yml b/deploy/roles/wireguard/meta/main.yml new file mode 100644 index 0000000..e06ba22 --- /dev/null +++ b/deploy/roles/wireguard/meta/main.yml @@ -0,0 +1,18 @@ +--- +galaxy_info: + author: DHIS2 + description: WireGuard VPN for securing DHIS2 monitoring and database access + license: BSD-3-Clause + min_ansible_version: "2.14" + platforms: + - name: Ubuntu + versions: + - jammy # 22.04 + - noble # 24.04 + galaxy_tags: + - wireguard + - vpn + - security + - dhis2 + +dependencies: [] diff --git a/deploy/roles/wireguard/tasks/firewall.yml b/deploy/roles/wireguard/tasks/firewall.yml new file mode 100644 index 0000000..74fc0a1 --- /dev/null +++ b/deploy/roles/wireguard/tasks/firewall.yml @@ -0,0 +1,25 @@ +--- +- name: Allow WireGuard UDP port {{ wireguard_port }} + community.general.ufw: + rule: allow + port: '{{ wireguard_port | string }}' + proto: udp + comment: 'WireGuard VPN' + +- name: Allow all traffic from VPN interface + community.general.ufw: + rule: allow + direction: in + interface: '{{ wireguard_interface }}' + comment: 'Allow all VPN client traffic on {{ wireguard_interface }}' + +- name: Add VPN↔LXD forwarding rules to /etc/ufw/before.rules + ansible.builtin.blockinfile: + path: /etc/ufw/before.rules + marker: '# {mark} WIREGUARD VPN FORWARDING' + insertafter: '^-A ufw-before-forward' + block: | + # Allow forwarding between WireGuard VPN and LXD containers + -A ufw-before-forward -i {{ wireguard_interface }} -o {{ lxd_bridge_interface }} -s {{ wireguard_network }} -d {{ lxd_network }} -j ACCEPT + -A ufw-before-forward -i {{ lxd_bridge_interface }} -o {{ wireguard_interface }} -s {{ lxd_network }} -d {{ wireguard_network }} -j ACCEPT + notify: Reload UFW diff --git a/deploy/roles/wireguard/tasks/lockdown_instances.yml b/deploy/roles/wireguard/tasks/lockdown_instances.yml new file mode 100644 index 0000000..16366fd --- /dev/null +++ b/deploy/roles/wireguard/tasks/lockdown_instances.yml @@ -0,0 +1,29 @@ +--- +- name: Check if munin-node is installed + ansible.builtin.stat: + path: /etc/munin/munin-node.conf + register: munin_node_stat + +- name: Allow Glowroot (4000/tcp) from LXD gateway only + community.general.ufw: + rule: allow + port: "4000" + src: "{{ lxd_gateway_ip }}" + proto: tcp + state: enabled + comment: "Glowroot APM — VPN access only (via LXD gateway NAT)" + when: + - app_monitoring is defined + - app_monitoring | trim == 'glowroot' + +- name: Allow munin-node (4949/tcp) from monitor container only + community.general.ufw: + rule: allow + port: "4949" + src: "{{ hostvars[groups['monitoring'][0]]['ansible_host'] }}" + proto: tcp + state: enabled + comment: "munin-node — monitor container access only" + when: + - munin_node_stat.stat.exists + - groups.get('monitoring', []) | length > 0 diff --git a/deploy/roles/wireguard/tasks/lockdown_monitor.yml b/deploy/roles/wireguard/tasks/lockdown_monitor.yml new file mode 100644 index 0000000..0434bf3 --- /dev/null +++ b/deploy/roles/wireguard/tasks/lockdown_monitor.yml @@ -0,0 +1,98 @@ +--- +- name: Check if Grafana is installed + ansible.builtin.stat: + path: /etc/grafana/grafana.ini + register: grafana_ini_stat + +- name: Reset Grafana root_url for direct access + community.general.ini_file: + path: /etc/grafana/grafana.ini + section: server + option: root_url + value: '%(protocol)s://%(domain)s:%(http_port)s/' + mode: '0640' + owner: root + group: grafana + notify: Restart Grafana + when: grafana_ini_stat.stat.exists + +- name: Disable Grafana serve_from_sub_path + community.general.ini_file: + path: /etc/grafana/grafana.ini + section: server + option: serve_from_sub_path + value: 'false' + mode: '0640' + owner: root + group: grafana + notify: Restart Grafana + when: grafana_ini_stat.stat.exists + +# Bind Grafana to all interfaces within the container. +# Access control is enforced by the host firewall + VPN, not by bind address. +- name: Ensure Grafana listens on all container interfaces + community.general.ini_file: + path: /etc/grafana/grafana.ini + section: server + option: http_addr + value: '0.0.0.0' + mode: '0640' + owner: root + group: grafana + notify: Restart Grafana + when: grafana_ini_stat.stat.exists + +- name: Check if Prometheus is installed + ansible.builtin.stat: + path: /etc/prometheus/prometheus.yml + register: prometheus_stat + +- name: Allow Grafana (3000/tcp) from LXD gateway only + community.general.ufw: + rule: allow + port: "{{ prometheus_http_port | default('3000') }}" + src: '{{ lxd_gateway_ip }}' + proto: tcp + state: enabled + comment: 'Grafana — VPN access only (via LXD gateway NAT)' + when: grafana_ini_stat.stat.exists + +# The monitoring role adds "allow /tcp from " before wireguard runs. +# Remove it so Grafana is unreachable from the proxy. Uses prometheus_http_port to +# match the rule the monitoring role created. delete: true is a no-op if already absent. +- name: Remove proxy→Grafana UFW rule + community.general.ufw: + rule: allow + port: "{{ prometheus_http_port | default('3000') }}" + src: "{{ hostvars[item]['ansible_host'] }}" + proto: tcp + delete: true + loop: "{{ groups.get('web', []) }}" + loop_control: + label: '{{ item }}' + when: grafana_ini_stat.stat.exists + +- name: Allow Prometheus (9090/tcp) from LXD gateway only + community.general.ufw: + rule: allow + port: '9090' + src: '{{ lxd_gateway_ip }}' + proto: tcp + state: enabled + comment: 'Prometheus — VPN access only (via LXD gateway NAT)' + when: prometheus_stat.stat.exists + +- name: Check if Munin is installed + ansible.builtin.stat: + path: /etc/munin/munin.conf + register: munin_conf_stat + +- name: Allow Munin (80/tcp) from LXD gateway only + community.general.ufw: + rule: allow + port: '80' + src: '{{ lxd_gateway_ip }}' + proto: tcp + state: enabled + comment: 'Munin — VPN access only (via LXD gateway NAT)' + when: munin_conf_stat.stat.exists diff --git a/deploy/roles/wireguard/tasks/lockdown_postgres.yml b/deploy/roles/wireguard/tasks/lockdown_postgres.yml new file mode 100644 index 0000000..83f09c3 --- /dev/null +++ b/deploy/roles/wireguard/tasks/lockdown_postgres.yml @@ -0,0 +1,24 @@ +--- +- name: Find pg_hba.conf location + ansible.builtin.shell: | + find /etc/postgresql -name pg_hba.conf -type f | head -1 + register: pg_hba_path + changed_when: false + +- name: Allow VPN direct psql — add lxd_gateway_ip to pg_hba.conf + ansible.builtin.lineinfile: + path: '{{ pg_hba_path.stdout | trim }}' + regexp: "^host\\s+all\\s+all\\s+{{ (lxd_gateway_ip + '/32') | replace('.', '\\.') | replace('/', '\\/') }}" + line: 'host all all {{ lxd_gateway_ip }}/32 scram-sha-256' + insertbefore: EOF + notify: Reload PostgreSQL + when: pg_hba_path.stdout | trim | length > 0 + +- name: Allow PostgreSQL (5432/tcp) from LXD gateway — VPN-only access + community.general.ufw: + rule: allow + port: '5432' + src: '{{ lxd_gateway_ip }}' + proto: tcp + state: enabled + comment: 'PostgreSQL — VPN access only (via LXD gateway NAT)' diff --git a/deploy/roles/wireguard/tasks/lockdown_proxy.yml b/deploy/roles/wireguard/tasks/lockdown_proxy.yml new file mode 100644 index 0000000..ceb3b53 --- /dev/null +++ b/deploy/roles/wireguard/tasks/lockdown_proxy.yml @@ -0,0 +1,42 @@ +--- +- name: Find monitoring nginx upstream configs + ansible.builtin.find: + paths: /etc/nginx/conf.d/upstream + patterns: + - 'munin*.conf' + - 'grafana*.conf' + register: nginx_monitoring_configs + when: proxy | default('nginx') == 'nginx' + +- name: Remove monitoring configs from nginx proxy + ansible.builtin.file: + path: '{{ item.path }}' + state: absent + loop: '{{ nginx_monitoring_configs.files | default([]) }}' + loop_control: + label: '{{ item.path | basename }}' + notify: Reload Nginx + when: + - proxy | default('nginx') == 'nginx' + - nginx_monitoring_configs.files | default([]) | length > 0 + +- name: Find monitoring apache2 site configs + ansible.builtin.find: + paths: /etc/apache2/sites-enabled + patterns: + - 'munin*.conf' + - 'grafana*.conf' + register: apache_monitoring_configs + when: proxy | default('nginx') == 'apache2' + +- name: Remove monitoring configs from apache2 proxy + ansible.builtin.file: + path: '{{ item.path }}' + state: absent + loop: '{{ apache_monitoring_configs.files | default([]) }}' + loop_control: + label: '{{ item.path | basename }}' + notify: Reload Apache2 + when: + - proxy | default('nginx') == 'apache2' + - apache_monitoring_configs.files | default([]) | length > 0 diff --git a/deploy/roles/wireguard/tasks/main.yml b/deploy/roles/wireguard/tasks/main.yml new file mode 100644 index 0000000..45d2e2d --- /dev/null +++ b/deploy/roles/wireguard/tasks/main.yml @@ -0,0 +1,41 @@ +--- +- name: WireGuard VPN | Server setup + ansible.builtin.include_tasks: server.yml + when: + - wireguard_enabled | bool + - inventory_hostname == '127.0.0.1' + +- name: WireGuard VPN | Firewall rules + ansible.builtin.include_tasks: firewall.yml + when: + - wireguard_enabled | bool + - inventory_hostname == '127.0.0.1' + +- name: WireGuard VPN | Lock down proxy + ansible.builtin.include_tasks: lockdown_proxy.yml + when: + - wireguard_enabled | bool + - wireguard_lockdown_monitoring | bool + - inventory_hostname in groups.get('web', []) + +- name: WireGuard VPN | Lock down monitoring + ansible.builtin.include_tasks: lockdown_monitor.yml + when: + - wireguard_enabled | bool + - wireguard_lockdown_monitoring | bool + - inventory_hostname in groups.get('monitoring', []) + +- name: WireGuard VPN | Lock down PostgreSQL + ansible.builtin.include_tasks: lockdown_postgres.yml + when: + - wireguard_enabled | bool + - wireguard_lockdown_monitoring | bool + - inventory_hostname in groups.get('databases', []) + +# Glowroot (4000, app_monitoring=glowroot only) and munin-node (4949, all instances). +- name: WireGuard VPN | Lock down instances + ansible.builtin.include_tasks: lockdown_instances.yml + when: + - wireguard_enabled | bool + - wireguard_lockdown_monitoring | bool + - inventory_hostname in groups.get('instances', []) diff --git a/deploy/roles/wireguard/tasks/server.yml b/deploy/roles/wireguard/tasks/server.yml new file mode 100644 index 0000000..e762b32 --- /dev/null +++ b/deploy/roles/wireguard/tasks/server.yml @@ -0,0 +1,131 @@ +--- +- name: Gather network facts for client config endpoint fallback + ansible.builtin.setup: + gather_subset: + - '!all' + - 'network' + +- name: Install WireGuard packages + ansible.builtin.apt: + name: + - wireguard + - wireguard-tools + state: present + update_cache: true + cache_valid_time: 3600 + +- name: Ensure /etc/wireguard directory exists with secure permissions + ansible.builtin.file: + path: /etc/wireguard + state: directory + owner: root + group: root + mode: '0700' + +- name: Ensure clients config directory exists + ansible.builtin.file: + path: /etc/wireguard/clients + state: directory + owner: root + group: root + mode: '0700' + +# Keys are generated once and preserved across playbook runs. +- name: Check if server private key exists + ansible.builtin.stat: + path: /etc/wireguard/server_private.key + register: wg_private_key_stat + +- name: Generate WireGuard server keypair + ansible.builtin.shell: | + set -o pipefail + umask 077 + wg genkey | tee /etc/wireguard/server_private.key | wg pubkey > /etc/wireguard/server_public.key + args: + executable: /bin/bash + when: not wg_private_key_stat.stat.exists + changed_when: true + +- name: Set restrictive permissions on key files + ansible.builtin.file: + path: '/etc/wireguard/{{ item }}' + owner: root + group: root + mode: '0600' + loop: + - server_private.key + - server_public.key + +- name: Read server private key + ansible.builtin.slurp: + src: /etc/wireguard/server_private.key + register: wg_server_private_key + no_log: true + +- name: Read server public key + ansible.builtin.slurp: + src: /etc/wireguard/server_public.key + register: wg_server_public_key + +- name: Display server public key (share with VPN clients) + ansible.builtin.debug: + msg: >- + WireGuard Server Public Key: + {{ wg_server_public_key.content | b64decode | trim }} + +# Required for routing VPN traffic into the LXD bridge. +- name: Enable net.ipv4.ip_forward persistently + ansible.posix.sysctl: + name: net.ipv4.ip_forward + value: '1' + state: present + reload: true + sysctl_set: true + +- name: Deploy WireGuard server configuration + ansible.builtin.template: + src: wg0.conf.j2 + dest: '/etc/wireguard/{{ wireguard_interface }}.conf' + owner: root + group: root + mode: '0600' + # First-time setup (no existing key) requires a full restart to bring up the interface. + # Subsequent runs use syncconf to apply peer changes atomically without dropping sessions. + # Ref: man wg(8) — syncconf + notify: "{{ 'Sync WireGuard peers' if wg_private_key_stat.stat.exists else 'Restart WireGuard' }}" + +- name: Enable and start WireGuard service + ansible.builtin.systemd: + name: 'wg-quick@{{ wireguard_interface }}' + state: started + enabled: true + daemon_reload: true + +- name: Warn when no VPN peers are configured + ansible.builtin.debug: + msg: >- + WARNING: wireguard_peers is empty. WireGuard will start but no client + can connect. Add at least one peer to your inventory before use. + when: wireguard_peers | length == 0 + +# Generated as a convenience; admins still need to paste their own private key. +- name: Generate client configuration files + ansible.builtin.template: + src: client.conf.j2 + dest: '/etc/wireguard/clients/{{ item.name }}.conf' + owner: root + group: root + mode: '0600' + no_log: true + loop: '{{ wireguard_peers }}' + loop_control: + label: '{{ item.name }}' + when: wireguard_peers | length > 0 + +- name: Client config generation summary + ansible.builtin.debug: + msg: >- + {{ wireguard_peers | length }} client config(s) generated in + /etc/wireguard/clients/. Each client must paste their own private key. + Generate QR codes with: qrencode -t ansiutf8 < /etc/wireguard/clients/.conf + when: wireguard_peers | length > 0 diff --git a/deploy/roles/wireguard/templates/client.conf.j2 b/deploy/roles/wireguard/templates/client.conf.j2 new file mode 100644 index 0000000..2c8e35a --- /dev/null +++ b/deploy/roles/wireguard/templates/client.conf.j2 @@ -0,0 +1,26 @@ +# WireGuard Client: {{ item.name }} +# Generated by Ansible — do not commit private keys to version control +# +# Import into WireGuard client: +# Linux: wg-quick up /path/to/{{ item.name }}.conf +# macOS: WireGuard app → Import tunnel(s) from file +# Windows: WireGuard app → Import tunnel(s) from file +# Mobile: qrencode -t ansiutf8 < {{ item.name }}.conf +# +# IMPORTANT: Replace with the private +# key that corresponds to the public key configured for this peer. + +[Interface] +Address = {{ item.allowed_ips }} +PrivateKey = +# DNS = 1.1.1.1, 8.8.8.8 + +[Peer] +PublicKey = {{ wg_server_public_key.content | b64decode | trim }} +{% if item.preshared_key is defined %} +PresharedKey = {{ item.preshared_key }} +{% endif %} +Endpoint = {{ fqdn | default(ansible_default_ipv4.address, true) }}:{{ wireguard_port }} +# Split-tunnel: only VPN subnet + LXD container subnet route through tunnel. +AllowedIPs = {{ wireguard_network }}, {{ lxd_network }} +PersistentKeepalive = 25 diff --git a/deploy/roles/wireguard/templates/wg0.conf.j2 b/deploy/roles/wireguard/templates/wg0.conf.j2 new file mode 100644 index 0000000..7bfc9db --- /dev/null +++ b/deploy/roles/wireguard/templates/wg0.conf.j2 @@ -0,0 +1,36 @@ +# Managed by Ansible — do not edit manually +# Architecture: VPN clients → wg0 → kernel forwarding → lxdbr1 → LXD containers + +[Interface] +Address = {{ wireguard_server_ip }}/24 +ListenPort = {{ wireguard_port }} +PrivateKey = {{ wg_server_private_key.content | b64decode | trim }} +# Prevent wg-quick from overwriting this Ansible-managed file with runtime state. +# Without this, 'wg-quick down' could persist dynamically-added peers back to disk. +SaveConfig = false + +# FORWARD rules: allow packets between wg0 and the LXD bridge. +# MASQUERADE: translate VPN source IPs so containers can reply through the bridge +# without needing a static route to 10.8.0.0/24. %i expands to the interface name. +PostUp = iptables -A FORWARD -i %i -o {{ lxd_bridge_interface }} -s {{ wireguard_network }} -d {{ lxd_network }} -j ACCEPT +PostUp = iptables -A FORWARD -i {{ lxd_bridge_interface }} -o %i -s {{ lxd_network }} -d {{ wireguard_network }} -j ACCEPT +PostUp = iptables -t nat -A POSTROUTING -s {{ wireguard_network }} -o {{ lxd_bridge_interface }} -j MASQUERADE +PostUp = ip6tables -A FORWARD -i %i -j DROP +PostUp = ip6tables -A FORWARD -o %i -j DROP + +PostDown = iptables -D FORWARD -i %i -o {{ lxd_bridge_interface }} -s {{ wireguard_network }} -d {{ lxd_network }} -j ACCEPT +PostDown = iptables -D FORWARD -i {{ lxd_bridge_interface }} -o %i -s {{ lxd_network }} -d {{ wireguard_network }} -j ACCEPT +PostDown = iptables -t nat -D POSTROUTING -s {{ wireguard_network }} -o {{ lxd_bridge_interface }} -j MASQUERADE +PostDown = ip6tables -D FORWARD -i %i -j DROP +PostDown = ip6tables -D FORWARD -o %i -j DROP + +{% for peer in wireguard_peers %} +[Peer] +# {{ peer.name }} +PublicKey = {{ peer.public_key }} +AllowedIPs = {{ peer.allowed_ips }} +{% if peer.preshared_key is defined %} +PresharedKey = {{ peer.preshared_key }} +{% endif %} + +{% endfor %} From aa03bf75f998dadb88c6fbc32387542d2f0f60c4 Mon Sep 17 00:00:00 2001 From: 0xafrogeek Date: Tue, 31 Mar 2026 01:54:32 +0000 Subject: [PATCH 02/14] chore: some cleanup --- deploy/roles/wireguard/tasks/lockdown_monitor.yml | 2 -- deploy/roles/wireguard/tasks/main.yml | 1 - deploy/roles/wireguard/tasks/server.yml | 6 ------ 3 files changed, 9 deletions(-) diff --git a/deploy/roles/wireguard/tasks/lockdown_monitor.yml b/deploy/roles/wireguard/tasks/lockdown_monitor.yml index 0434bf3..9b4e11f 100644 --- a/deploy/roles/wireguard/tasks/lockdown_monitor.yml +++ b/deploy/roles/wireguard/tasks/lockdown_monitor.yml @@ -28,8 +28,6 @@ notify: Restart Grafana when: grafana_ini_stat.stat.exists -# Bind Grafana to all interfaces within the container. -# Access control is enforced by the host firewall + VPN, not by bind address. - name: Ensure Grafana listens on all container interfaces community.general.ini_file: path: /etc/grafana/grafana.ini diff --git a/deploy/roles/wireguard/tasks/main.yml b/deploy/roles/wireguard/tasks/main.yml index 45d2e2d..af7f84b 100644 --- a/deploy/roles/wireguard/tasks/main.yml +++ b/deploy/roles/wireguard/tasks/main.yml @@ -32,7 +32,6 @@ - wireguard_lockdown_monitoring | bool - inventory_hostname in groups.get('databases', []) -# Glowroot (4000, app_monitoring=glowroot only) and munin-node (4949, all instances). - name: WireGuard VPN | Lock down instances ansible.builtin.include_tasks: lockdown_instances.yml when: diff --git a/deploy/roles/wireguard/tasks/server.yml b/deploy/roles/wireguard/tasks/server.yml index e762b32..6b9111f 100644 --- a/deploy/roles/wireguard/tasks/server.yml +++ b/deploy/roles/wireguard/tasks/server.yml @@ -30,7 +30,6 @@ group: root mode: '0700' -# Keys are generated once and preserved across playbook runs. - name: Check if server private key exists ansible.builtin.stat: path: /etc/wireguard/server_private.key @@ -73,7 +72,6 @@ WireGuard Server Public Key: {{ wg_server_public_key.content | b64decode | trim }} -# Required for routing VPN traffic into the LXD bridge. - name: Enable net.ipv4.ip_forward persistently ansible.posix.sysctl: name: net.ipv4.ip_forward @@ -89,9 +87,6 @@ owner: root group: root mode: '0600' - # First-time setup (no existing key) requires a full restart to bring up the interface. - # Subsequent runs use syncconf to apply peer changes atomically without dropping sessions. - # Ref: man wg(8) — syncconf notify: "{{ 'Sync WireGuard peers' if wg_private_key_stat.stat.exists else 'Restart WireGuard' }}" - name: Enable and start WireGuard service @@ -108,7 +103,6 @@ can connect. Add at least one peer to your inventory before use. when: wireguard_peers | length == 0 -# Generated as a convenience; admins still need to paste their own private key. - name: Generate client configuration files ansible.builtin.template: src: client.conf.j2 From 48cb827101c5add685203b8ddbdcc2b3209f7d1e Mon Sep 17 00:00:00 2001 From: 0xafrogeek Date: Thu, 2 Apr 2026 21:32:46 +0000 Subject: [PATCH 03/14] chore: update WireGuard inventory var comments for clarity --- deploy/inventory/hosts.template | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/deploy/inventory/hosts.template b/deploy/inventory/hosts.template index c30f0e1..7e4b829 100644 --- a/deploy/inventory/hosts.template +++ b/deploy/inventory/hosts.template @@ -50,11 +50,10 @@ postgresql_version=16 server_monitoring=munin app_monitoring=glowroot -# WireGuard VPN — restricts Grafana, Prometheus, Munin, and PostgreSQL to VPN-only access. -# Define peers in group_vars/all.yml (see group_vars/all.template). +# WireGuard VPN — restricts Grafana, Prometheus, Munin, and PostgreSQL to VPN-only access +# Define peers in group_vars/all.yml (see group_vars/all.template) +# Override wireguard_port or wireguard_lockdown_monitoring in host_vars/group_vars if needed wireguard_enabled=false -# wireguard_lockdown_monitoring=true -# wireguard_port=51820 # lxd From 9a61baf33561443523e97dbc1dc5c6790d1e1081 Mon Sep 17 00:00:00 2001 From: 0xafrogeek Date: Mon, 6 Apr 2026 17:28:40 +0000 Subject: [PATCH 04/14] feat: enhance WireGuard role with validation and improved configuration - Added validation tasks to ensure each VPN peer has required fields (name, public_key, allowed_ips) and that allowed_ips are unique. - Updated comments and documentation for clarity regarding VPN access restrictions. - Adjusted firewall rules and monitoring configurations to support the new setup. --- deploy/dhis2.yml | 4 +- deploy/inventory/group_vars/all.template | 16 ------ deploy/inventory/hosts.template | 4 +- deploy/roles/wireguard/defaults/main.yml | 28 ++------- deploy/roles/wireguard/handlers/main.yml | 2 +- .../roles/wireguard/meta/argument_specs.yml | 57 +++++++++++++++++++ deploy/roles/wireguard/tasks/firewall.yml | 12 ++-- .../wireguard/tasks/lockdown_instances.yml | 10 ++-- .../wireguard/tasks/lockdown_monitor.yml | 9 +-- .../wireguard/tasks/lockdown_postgres.yml | 2 +- .../roles/wireguard/tasks/lockdown_proxy.yml | 9 +-- deploy/roles/wireguard/tasks/main.yml | 9 +++ deploy/roles/wireguard/tasks/server.yml | 28 ++++----- deploy/roles/wireguard/tasks/validate.yml | 27 +++++++++ 14 files changed, 138 insertions(+), 79 deletions(-) delete mode 100644 deploy/inventory/group_vars/all.template create mode 100644 deploy/roles/wireguard/meta/argument_specs.yml create mode 100644 deploy/roles/wireguard/tasks/validate.yml diff --git a/deploy/dhis2.yml b/deploy/dhis2.yml index 669cc1b..e17537b 100644 --- a/deploy/dhis2.yml +++ b/deploy/dhis2.yml @@ -22,11 +22,11 @@ tags: [proxy-install] - role: monitoring tags: monitoring - - role: wireguard - tags: [wireguard] - role: create-instance tags: [create-instance] when: instance_state is not defined or instance_state != 'deleted' + - role: wireguard + tags: [wireguard] tasks: - name: Install and configure unattended_upgrades ansible.builtin.include_tasks: playbooks/unattended_upgrades.yml diff --git a/deploy/inventory/group_vars/all.template b/deploy/inventory/group_vars/all.template deleted file mode 100644 index 8e2befa..0000000 --- a/deploy/inventory/group_vars/all.template +++ /dev/null @@ -1,16 +0,0 @@ ---- -# This is just template. Ansible hosts are members of groups, in our tools we have 3 most commong groups, [web], [databases], [monitoring] - -# WireGuard VPN peers — one entry per VPN peer. -# Generate keys on the admin workstation: -# wg genkey | tee private.key | wg pubkey > public.key -# wg genpsk > preshared.key # optional, post-quantum resistance -# -# public_key and preshared_key should be vault-encrypted: -# ansible-vault encrypt_string '' --name '' -# -# wireguard_peers: -# - name: admin-alice -# public_key: "abc123...=" -# allowed_ips: "10.8.0.2/32" -# preshared_key: "xyz789...=" # optional — vault-encrypt if using it diff --git a/deploy/inventory/hosts.template b/deploy/inventory/hosts.template index 7e4b829..e10e5d6 100644 --- a/deploy/inventory/hosts.template +++ b/deploy/inventory/hosts.template @@ -50,8 +50,8 @@ postgresql_version=16 server_monitoring=munin app_monitoring=glowroot -# WireGuard VPN — restricts Grafana, Prometheus, Munin, and PostgreSQL to VPN-only access -# Define peers in group_vars/all.yml (see group_vars/all.template) +# WireGuard VPN — restricts Grafana, Prometheus, Munin, Glowroot and PostgreSQL to VPN-only access +# Define peers in group_vars/all.yml # Override wireguard_port or wireguard_lockdown_monitoring in host_vars/group_vars if needed wireguard_enabled=false diff --git a/deploy/roles/wireguard/defaults/main.yml b/deploy/roles/wireguard/defaults/main.yml index b66d56c..f79bf38 100644 --- a/deploy/roles/wireguard/defaults/main.yml +++ b/deploy/roles/wireguard/defaults/main.yml @@ -1,33 +1,17 @@ --- wireguard_enabled: false -# WireGuard VPN network -wireguard_network: '10.8.0.0/24' -wireguard_server_ip: '10.8.0.1' +wireguard_network: "10.8.0.0/24" +wireguard_server_ip: "10.8.0.1" wireguard_port: 51820 wireguard_interface: wg0 -lxd_bridge_interface: 'lxdbr1' +lxd_bridge_interface: "lxdbr1" +lxd_network: "172.19.2.0/24" +lxd_gateway_ip: "172.19.2.1" -lxd_network: '172.19.2.0/24' +grafana_port: 3000 -# LXD gateway IP — the host-side IP of the LXD bridge. -# After MASQUERADE (wg0.conf.j2 PostUp), container UFW sees traffic sourced -# from this IP, NOT from wireguard_network (10.8.0.x). -# Verify with: lxc network show lxdbr1 | grep ipv4.address -lxd_gateway_ip: '172.19.2.1' - -# VPN peers (clients) -# Generate keys on the admin workstation: -# wg genkey | tee private.key | wg pubkey > public.key -# wg genpsk > preshared.key (optional, post-quantum) -# -# Example: -# wireguard_peers: -# - name: admin-alice -# public_key: "abc123...=" -# allowed_ips: "10.8.0.2/32" -# preshared_key: "xyz789...=" # optional wireguard_peers: [] wireguard_lockdown_monitoring: true diff --git a/deploy/roles/wireguard/handlers/main.yml b/deploy/roles/wireguard/handlers/main.yml index 483d52b..198167a 100644 --- a/deploy/roles/wireguard/handlers/main.yml +++ b/deploy/roles/wireguard/handlers/main.yml @@ -1,7 +1,7 @@ --- - name: Restart WireGuard ansible.builtin.systemd: - name: 'wg-quick@{{ wireguard_interface }}' + name: "wg-quick@{{ wireguard_interface }}" state: restarted daemon_reload: true diff --git a/deploy/roles/wireguard/meta/argument_specs.yml b/deploy/roles/wireguard/meta/argument_specs.yml new file mode 100644 index 0000000..58b6abc --- /dev/null +++ b/deploy/roles/wireguard/meta/argument_specs.yml @@ -0,0 +1,57 @@ +--- +argument_specs: + main: + short_description: "WireGuard VPN for securing DHIS2 monitoring and database access" + description: + - Installs and configures a WireGuard VPN server on the host machine. + - Optionally locks down monitoring dashboards and PostgreSQL to VPN-only access. + options: + wireguard_enabled: + description: "Master switch — enable the WireGuard VPN role" + type: bool + default: false + wireguard_network: + description: "VPN subnet in CIDR notation" + type: str + default: "10.8.0.0/24" + wireguard_server_ip: + description: "Server IP address on the VPN subnet" + type: str + default: "10.8.0.1" + wireguard_port: + description: "UDP listen port for WireGuard" + type: int + default: 51820 + wireguard_interface: + description: "WireGuard network interface name" + type: str + default: "wg0" + lxd_bridge_interface: + description: "LXD bridge interface name (must match the LXD network)" + type: str + default: "lxdbr1" + lxd_network: + description: "LXD container subnet in CIDR notation" + type: str + default: "172.19.2.0/24" + lxd_gateway_ip: + description: "Host-side IP of the LXD bridge" + type: str + default: "172.19.2.1" + wireguard_peers: + description: >- + List of VPN client peers. Each entry requires name, public_key, + and allowed_ips. Optional: preshared_key. + type: list + elements: dict + default: [] + wireguard_lockdown_monitoring: + description: >- + When true, removes monitoring paths from the public proxy + and restricts monitoring/DB ports to VPN-only access. + type: bool + default: true + grafana_port: + description: "Grafana HTTP port — used for VPN-only UFW rules" + type: int + default: 3000 diff --git a/deploy/roles/wireguard/tasks/firewall.yml b/deploy/roles/wireguard/tasks/firewall.yml index 74fc0a1..91420c0 100644 --- a/deploy/roles/wireguard/tasks/firewall.yml +++ b/deploy/roles/wireguard/tasks/firewall.yml @@ -2,22 +2,22 @@ - name: Allow WireGuard UDP port {{ wireguard_port }} community.general.ufw: rule: allow - port: '{{ wireguard_port | string }}' + port: "{{ wireguard_port | string }}" proto: udp - comment: 'WireGuard VPN' + comment: "WireGuard VPN" - name: Allow all traffic from VPN interface community.general.ufw: rule: allow direction: in - interface: '{{ wireguard_interface }}' - comment: 'Allow all VPN client traffic on {{ wireguard_interface }}' + interface: "{{ wireguard_interface }}" + comment: "Allow all VPN client traffic on {{ wireguard_interface }}" - name: Add VPN↔LXD forwarding rules to /etc/ufw/before.rules ansible.builtin.blockinfile: path: /etc/ufw/before.rules - marker: '# {mark} WIREGUARD VPN FORWARDING' - insertafter: '^-A ufw-before-forward' + marker: "# {mark} WIREGUARD VPN FORWARDING" + insertafter: "^-A ufw-before-forward" block: | # Allow forwarding between WireGuard VPN and LXD containers -A ufw-before-forward -i {{ wireguard_interface }} -o {{ lxd_bridge_interface }} -s {{ wireguard_network }} -d {{ lxd_network }} -j ACCEPT diff --git a/deploy/roles/wireguard/tasks/lockdown_instances.yml b/deploy/roles/wireguard/tasks/lockdown_instances.yml index 16366fd..a5582f2 100644 --- a/deploy/roles/wireguard/tasks/lockdown_instances.yml +++ b/deploy/roles/wireguard/tasks/lockdown_instances.yml @@ -7,11 +7,11 @@ - name: Allow Glowroot (4000/tcp) from LXD gateway only community.general.ufw: rule: allow - port: "4000" - src: "{{ lxd_gateway_ip }}" + port: '4000' + src: '{{ lxd_gateway_ip }}' proto: tcp state: enabled - comment: "Glowroot APM — VPN access only (via LXD gateway NAT)" + comment: 'Glowroot APM — VPN access only (via LXD gateway NAT)' when: - app_monitoring is defined - app_monitoring | trim == 'glowroot' @@ -19,11 +19,11 @@ - name: Allow munin-node (4949/tcp) from monitor container only community.general.ufw: rule: allow - port: "4949" + port: '4949' src: "{{ hostvars[groups['monitoring'][0]]['ansible_host'] }}" proto: tcp state: enabled - comment: "munin-node — monitor container access only" + comment: 'munin-node — monitor container access only' when: - munin_node_stat.stat.exists - groups.get('monitoring', []) | length > 0 diff --git a/deploy/roles/wireguard/tasks/lockdown_monitor.yml b/deploy/roles/wireguard/tasks/lockdown_monitor.yml index 9b4e11f..40d22fc 100644 --- a/deploy/roles/wireguard/tasks/lockdown_monitor.yml +++ b/deploy/roles/wireguard/tasks/lockdown_monitor.yml @@ -48,20 +48,17 @@ - name: Allow Grafana (3000/tcp) from LXD gateway only community.general.ufw: rule: allow - port: "{{ prometheus_http_port | default('3000') }}" + port: "{{ grafana_port }}" src: '{{ lxd_gateway_ip }}' proto: tcp state: enabled comment: 'Grafana — VPN access only (via LXD gateway NAT)' when: grafana_ini_stat.stat.exists -# The monitoring role adds "allow /tcp from " before wireguard runs. -# Remove it so Grafana is unreachable from the proxy. Uses prometheus_http_port to -# match the rule the monitoring role created. delete: true is a no-op if already absent. -- name: Remove proxy→Grafana UFW rule +- name: Remove proxy -> Grafana UFW rule community.general.ufw: rule: allow - port: "{{ prometheus_http_port | default('3000') }}" + port: "{{ grafana_port }}" src: "{{ hostvars[item]['ansible_host'] }}" proto: tcp delete: true diff --git a/deploy/roles/wireguard/tasks/lockdown_postgres.yml b/deploy/roles/wireguard/tasks/lockdown_postgres.yml index 83f09c3..f866584 100644 --- a/deploy/roles/wireguard/tasks/lockdown_postgres.yml +++ b/deploy/roles/wireguard/tasks/lockdown_postgres.yml @@ -5,7 +5,7 @@ register: pg_hba_path changed_when: false -- name: Allow VPN direct psql — add lxd_gateway_ip to pg_hba.conf +- name: Allow VPN admin access — add lxd_gateway_ip to pg_hba.conf (all databases, all users) ansible.builtin.lineinfile: path: '{{ pg_hba_path.stdout | trim }}' regexp: "^host\\s+all\\s+all\\s+{{ (lxd_gateway_ip + '/32') | replace('.', '\\.') | replace('/', '\\/') }}" diff --git a/deploy/roles/wireguard/tasks/lockdown_proxy.yml b/deploy/roles/wireguard/tasks/lockdown_proxy.yml index ceb3b53..2e74a65 100644 --- a/deploy/roles/wireguard/tasks/lockdown_proxy.yml +++ b/deploy/roles/wireguard/tasks/lockdown_proxy.yml @@ -8,10 +8,11 @@ register: nginx_monitoring_configs when: proxy | default('nginx') == 'nginx' -- name: Remove monitoring configs from nginx proxy - ansible.builtin.file: - path: '{{ item.path }}' - state: absent +- name: Empty monitoring configs on nginx proxy (keep files so include directives still resolve) + ansible.builtin.copy: + content: "# Monitoring disabled by WireGuard lockdown\n" + dest: '{{ item.path }}' + mode: '0644' loop: '{{ nginx_monitoring_configs.files | default([]) }}' loop_control: label: '{{ item.path | basename }}' diff --git a/deploy/roles/wireguard/tasks/main.yml b/deploy/roles/wireguard/tasks/main.yml index af7f84b..40af5d0 100644 --- a/deploy/roles/wireguard/tasks/main.yml +++ b/deploy/roles/wireguard/tasks/main.yml @@ -1,4 +1,12 @@ --- +- name: WireGuard VPN | Validate peer configuration + ansible.builtin.include_tasks: validate.yml + when: + - wireguard_enabled | bool + - wireguard_peers | length > 0 + tags: + - always + - name: WireGuard VPN | Server setup ansible.builtin.include_tasks: server.yml when: @@ -32,6 +40,7 @@ - wireguard_lockdown_monitoring | bool - inventory_hostname in groups.get('databases', []) +# Glowroot (4000, app_monitoring=glowroot only) and munin-node (4949, all instances). - name: WireGuard VPN | Lock down instances ansible.builtin.include_tasks: lockdown_instances.yml when: diff --git a/deploy/roles/wireguard/tasks/server.yml b/deploy/roles/wireguard/tasks/server.yml index 6b9111f..7083546 100644 --- a/deploy/roles/wireguard/tasks/server.yml +++ b/deploy/roles/wireguard/tasks/server.yml @@ -2,8 +2,8 @@ - name: Gather network facts for client config endpoint fallback ansible.builtin.setup: gather_subset: - - '!all' - - 'network' + - "!all" + - "network" - name: Install WireGuard packages ansible.builtin.apt: @@ -20,7 +20,7 @@ state: directory owner: root group: root - mode: '0700' + mode: "0700" - name: Ensure clients config directory exists ansible.builtin.file: @@ -28,7 +28,7 @@ state: directory owner: root group: root - mode: '0700' + mode: "0700" - name: Check if server private key exists ansible.builtin.stat: @@ -47,10 +47,10 @@ - name: Set restrictive permissions on key files ansible.builtin.file: - path: '/etc/wireguard/{{ item }}' + path: "/etc/wireguard/{{ item }}" owner: root group: root - mode: '0600' + mode: "0600" loop: - server_private.key - server_public.key @@ -75,7 +75,7 @@ - name: Enable net.ipv4.ip_forward persistently ansible.posix.sysctl: name: net.ipv4.ip_forward - value: '1' + value: "1" state: present reload: true sysctl_set: true @@ -83,15 +83,15 @@ - name: Deploy WireGuard server configuration ansible.builtin.template: src: wg0.conf.j2 - dest: '/etc/wireguard/{{ wireguard_interface }}.conf' + dest: "/etc/wireguard/{{ wireguard_interface }}.conf" owner: root group: root - mode: '0600' + mode: "0600" notify: "{{ 'Sync WireGuard peers' if wg_private_key_stat.stat.exists else 'Restart WireGuard' }}" - name: Enable and start WireGuard service ansible.builtin.systemd: - name: 'wg-quick@{{ wireguard_interface }}' + name: "wg-quick@{{ wireguard_interface }}" state: started enabled: true daemon_reload: true @@ -106,14 +106,14 @@ - name: Generate client configuration files ansible.builtin.template: src: client.conf.j2 - dest: '/etc/wireguard/clients/{{ item.name }}.conf' + dest: "/etc/wireguard/clients/{{ item.name }}.conf" owner: root group: root - mode: '0600' + mode: "0600" no_log: true - loop: '{{ wireguard_peers }}' + loop: "{{ wireguard_peers }}" loop_control: - label: '{{ item.name }}' + label: "{{ item.name }}" when: wireguard_peers | length > 0 - name: Client config generation summary diff --git a/deploy/roles/wireguard/tasks/validate.yml b/deploy/roles/wireguard/tasks/validate.yml new file mode 100644 index 0000000..b762e5a --- /dev/null +++ b/deploy/roles/wireguard/tasks/validate.yml @@ -0,0 +1,27 @@ +--- +- name: WireGuard | Assert each peer has required fields (name, public_key, allowed_ips) + ansible.builtin.assert: + that: + - item.name is defined and item.name | length > 0 + - item.public_key is defined and item.public_key | length > 0 + - item.allowed_ips is defined and item.allowed_ips | length > 0 + fail_msg: >- + wireguard_peers[{{ idx }}] is missing a required field. + Each peer must define name, public_key, and allowed_ips. + Got: {{ item | dict2items | map(attribute='key') | list }} + quiet: true + loop: "{{ wireguard_peers }}" + loop_control: + index_var: idx + label: "{{ item.name | default('UNNAMED-peer-' + idx | string) }}" + +- name: WireGuard | Assert no duplicate allowed_ips across peers + ansible.builtin.assert: + that: + - wireguard_peers | map(attribute='allowed_ips') | list | unique | length == wireguard_peers | length + fail_msg: >- + Duplicate allowed_ips detected in wireguard_peers. + Each peer must have a unique allowed_ips value to avoid silent routing conflicts. + Values: {{ wireguard_peers | map(attribute='allowed_ips') | list }} + quiet: true + when: wireguard_peers | length > 1 From 8062bef578dd903c583f908e126ade4cdfa0ccc1 Mon Sep 17 00:00:00 2001 From: 0xafrogeek Date: Mon, 6 Apr 2026 17:29:54 +0000 Subject: [PATCH 05/14] docs: add WireGuard documentation --- docs/WireGuard-VPN.md | 303 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 docs/WireGuard-VPN.md diff --git a/docs/WireGuard-VPN.md b/docs/WireGuard-VPN.md new file mode 100644 index 0000000..d8c5ec2 --- /dev/null +++ b/docs/WireGuard-VPN.md @@ -0,0 +1,303 @@ +# WireGuard VPN for DHIS2 Server Tools + +WireGuard provides a secure VPN tunnel for administering DHIS2 infrastructure. When enabled, monitoring dashboards (Grafana, Prometheus, Munin), application performance monitoring (Glowroot), and direct PostgreSQL access are restricted to VPN-connected clients only. Public DHIS2 web access remains unaffected. + +## Architecture + +``` + Internet + │ + ┌──────┴──────┐ + │ Host (UFW) │ + │ │ + ┌──────┤ wg0 │◄──── VPN Clients (10.8.0.0/24) + │ │ 10.8.0.1 │ UDP port 51820 + │ └──────┬──────┘ + │ │ + │ lxdbr1 (172.19.2.1/24) + │ │ + ┌────────┼─────────────┼─────────────┐ + │ │ │ │ + ┌───┴───┐ ┌─┴──────┐ ┌────┴────┐ ┌──────┴───┐ + │ proxy │ │postgres│ │ dhis │ │ monitor │ + │ .2 │ │ .20 │ │ .11 │ │ .30 │ + └───────┘ └────────┘ └────────┘ └──────────┘ +``` + +**Traffic flow:** + +1. VPN client connects to the host on UDP port 51820. +2. The host kernel forwards packets from `wg0` to `lxdbr1` via iptables FORWARD rules. +3. MASQUERADE translates VPN source IPs (10.8.0.x) to the LXD gateway IP (172.19.2.1) so containers can route replies without needing a static route back to the VPN subnet. +4. Container-level UFW rules allow traffic only from the LXD gateway IP, ensuring that only VPN-routed traffic reaches protected services. + +## Prerequisites + +- Ubuntu 22.04+ (kernel >= 5.6 includes the WireGuard kernel module) +- UFW firewall enabled on the host +- A working dhis2-server-tools LXD deployment (or ready to deploy) +- WireGuard client installed on your admin workstation + +## Quick Start + +### 1. Generate Client Keys + +On your **admin workstation** (not the server): + +```bash +# Install WireGuard tools +sudo apt install wireguard-tools # Ubuntu/Debian +brew install wireguard-tools # macOS + +# Generate keypair +wg genkey | tee private.key | wg pubkey > public.key + +# Optional: generate a preshared key (post-quantum resistance) +wg genpsk > preshared.key +``` + +Keep `private.key` secure. You will need `public.key` (and optionally `preshared.key`) for the inventory configuration. + +### 2. Configure the Inventory + +Edit `deploy/inventory/hosts` and set: + +```ini +[all:vars] +wireguard_enabled=true +``` + +### 3. Define VPN Peers + +Edit `deploy/inventory/group_vars/all/vars.yml`: + +```yaml +wireguard_peers: + - name: admin-alice + public_key: "paste-alice-public-key-here" + allowed_ips: "10.8.0.2/32" + preshared_key: "paste-preshared-key-here" # optional + + - name: admin-bob + public_key: "paste-bob-public-key-here" + allowed_ips: "10.8.0.3/32" +``` + +Each peer must have a unique IP address within the `10.8.0.0/24` subnet. The server uses `10.8.0.1`; assign clients starting from `10.8.0.2`. + +### 4. Deploy + +```bash +cd deploy/ + +# Full deployment (includes WireGuard) +sudo ./deploy.sh + +# Or deploy only WireGuard on an existing setup +sudo ansible-playbook dhis2.yml --tags wireguard +``` + +The playbook will: +- Install WireGuard packages on the host +- Generate server keypair (preserved across runs) +- Deploy the `wg0` interface configuration +- Configure UFW and iptables forwarding rules +- Lock down monitoring and database access to VPN-only +- Generate client configuration templates in `/etc/wireguard/clients/` + +### 5. Configure the Client + +After deployment, the server displays its public key and generates client config files. + +**Retrieve the client config from the server:** + +```bash +sudo cat /etc/wireguard/clients/admin-alice.conf +``` + +The generated config looks like: + +```ini +[Interface] +Address = 10.8.0.2/32 +PrivateKey = +# DNS = 1.1.1.1, 8.8.8.8 + +[Peer] +PublicKey = +Endpoint = your-server.example.com:51820 +AllowedIPs = 10.8.0.0/24, 172.19.2.0/24 +PersistentKeepalive = 25 +``` + +**Edit the config** and replace `` with the private key you generated in step 1. + +**Connect:** + +```bash +# Linux +sudo wg-quick up /path/to/admin-alice.conf + +# macOS / Windows +# Import the .conf file into the WireGuard app + +# Mobile (generate QR code on the server) +sudo qrencode -t ansiutf8 < /etc/wireguard/clients/admin-alice.conf +``` + +### 6. Verify Connectivity + +```bash +# Check VPN interface is up +sudo wg show + +# Ping the VPN server +ping 10.8.0.1 + +# Ping an LXD container (e.g., monitoring) +ping 172.19.2.30 + +# Access Grafana directly (if monitoring is Grafana/Prometheus) +curl http://172.19.2.30:3000 + +# Access Munin directly (if monitoring is Munin) +curl http://172.19.2.30/munin/ + +# Connect to PostgreSQL directly +psql -h 172.19.2.20 -U dhis -d dhis2 +``` + +## What Gets Locked Down + +When `wireguard_enabled=true` and `wireguard_lockdown_monitoring=true` (default): + +| Service | Container | Port | Before VPN | After VPN | +|---|---|---|---|---| +| Grafana | monitor | 3000 | Accessible via proxy `/grafana` | VPN-only direct access | +| Prometheus | monitor | 9090 | Accessible via proxy | VPN-only direct access | +| Munin | monitor | 80 | Accessible via proxy `/munin` | VPN-only direct access | +| Glowroot | dhis instances | 4000 | Accessible from LXD network | VPN-only via LXD gateway | +| munin-node | dhis instances | 4949 | Accessible from monitor container | Monitor container only | +| PostgreSQL | postgres | 5432 | LXD network only | VPN-only direct access | + +**Not affected:** DHIS2 web application (HTTP/HTTPS on ports 80/443 through the proxy) remains publicly accessible. + +### PostgreSQL VPN access + +The VPN lockdown adds a `host all all /32 scram-sha-256` entry to `pg_hba.conf`. This grants VPN users access to all databases as any PostgreSQL user — intentionally broad to support ad-hoc admin access (`psql`) over the VPN. A password is still required (`scram-sha-256`). Application-level pg_hba entries (per-instance db/user restrictions) are managed separately by the `create-instance` role. + +### Restoring public monitoring access + +Enabling `wireguard_lockdown_monitoring` removes monitoring proxy configs (Grafana/Munin paths) from nginx/apache2. To restore public proxy access: + +1. Set `wireguard_lockdown_monitoring: false` in your inventory. +2. Re-run the playbook: + ```bash + sudo ansible-playbook dhis2.yml --tags monitoring,proxy-install,wireguard + ``` + +The monitoring and proxy roles recreate the deleted config files idempotently. + +## Configuration Reference + +All variables are set in `deploy/roles/wireguard/defaults/main.yml` and can be overridden in the inventory. + +| Variable | Default | Description | +|---|---|---| +| `wireguard_enabled` | `false` | Master switch — must be `true` to activate | +| `wireguard_network` | `10.8.0.0/24` | VPN subnet | +| `wireguard_server_ip` | `10.8.0.1` | Server address on the VPN | +| `wireguard_port` | `51820` | UDP listen port | +| `wireguard_interface` | `wg0` | WireGuard interface name | +| `lxd_bridge_interface` | `lxdbr1` | LXD bridge (must match your LXD network) | +| `lxd_network` | `172.19.2.0/24` | LXD container subnet | +| `lxd_gateway_ip` | `172.19.2.1` | Host-side IP of the LXD bridge | +| `wireguard_lockdown_monitoring` | `true` | Restrict monitoring/DB to VPN-only | +| `wireguard_peers` | `[]` | List of VPN client peers | + +### Peer definition + +Each entry in `wireguard_peers` accepts: + +| Field | Required | Description | +|---|---|---| +| `name` | Yes | Identifier (used for client config filename) | +| `public_key` | Yes | Client's WireGuard public key | +| `allowed_ips` | Yes | Client's VPN IP (e.g., `10.8.0.2/32`) | +| `preshared_key` | No | Optional preshared key for post-quantum security | + +## Split Tunneling + +The client configuration uses **split tunneling** by default: only traffic destined for the VPN subnet (`10.8.0.0/24`) and the LXD container subnet (`172.19.2.0/24`) routes through the tunnel. All other internet traffic uses the client's normal connection. + +To route all traffic through the VPN (full tunnel), change the client config: + +```ini +# Split tunnel (default) +AllowedIPs = 10.8.0.0/24, 172.19.2.0/24 + +# Full tunnel +AllowedIPs = 0.0.0.0/0 +``` + +## Adding and Removing Peers + +### Adding a new peer + +1. Generate keys on the new client's workstation. +2. Add the peer to `wireguard_peers` in your inventory. +3. Re-run the playbook: + +```bash +sudo ansible-playbook dhis2.yml --tags wireguard +``` + +The playbook uses `wg syncconf` to apply peer changes **without dropping existing VPN sessions**. + +### Removing a peer + +1. Remove the peer entry from `wireguard_peers`. +2. Re-run the playbook: + +```bash +sudo ansible-playbook dhis2.yml --tags wireguard +``` + +The `wg syncconf` command removes peers that are no longer in the configuration. + +## Network Customization + +### Changing the VPN subnet + +If `10.8.0.0/24` conflicts with your network, override it in the inventory: + +```ini +[all:vars] +wireguard_network=10.99.0.0/24 +wireguard_server_ip=10.99.0.1 +``` + +Update peer `allowed_ips` accordingly and re-deploy. + +### Changing the WireGuard port + +```ini +[all:vars] +wireguard_port=41820 +``` + +Ensure the new port is open on any upstream firewalls or cloud security groups. + +## Disabling WireGuard + +Set `wireguard_enabled=false` in the inventory. This prevents the WireGuard tasks from running on subsequent playbook runs but does **not** tear down an existing WireGuard interface. To fully remove WireGuard: + +```bash +# On the host +sudo wg-quick down wg0 +sudo systemctl disable wg-quick@wg0 +sudo apt remove wireguard wireguard-tools +sudo rm -rf /etc/wireguard +``` + +Then manually revert any UFW rule changes and re-enable proxy monitoring paths if needed by re-running the monitoring and proxy roles. From dfe29edc95501b19888339c86ca965d7aece78f1 Mon Sep 17 00:00:00 2001 From: 0xafrogeek Date: Wed, 15 Apr 2026 22:47:29 +0000 Subject: [PATCH 06/14] chore: update .gitignore and add WireGuard VPN peers configuration - Modified .gitignore to allow specific files in group_vars and host_vars directories. - Updated hosts.template to reflect changes in configuration file paths. - Added vars.yml for WireGuard VPN peers configuration, including key generation instructions. --- .gitignore | 7 +++++-- deploy/inventory/group_vars/all/vars.yml | 10 ++++++++++ deploy/inventory/hosts.template | 4 ++-- 3 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 deploy/inventory/group_vars/all/vars.yml diff --git a/.gitignore b/.gitignore index 2a7a69f..c3379e2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,12 +9,15 @@ venv/ .venv/ .claude/ -# Ignore all files in deploy/inventory/host_vars/ and deploy/inventory/group_vars/ +# Ignore all files in deploy/inventory/host_vars/ and deploy/inventory/group_vars/ # but allow .template files deploy/inventory/host_vars/* -deploy/inventory/group_vars/* !deploy/inventory/host_vars/*.template +deploy/inventory/group_vars/* !deploy/inventory/group_vars/*.template +!deploy/inventory/group_vars/all/ +deploy/inventory/group_vars/all/* +!deploy/inventory/group_vars/all/vars.yml deploy/filter_plugins/__pycache__/ # Testing artifacts diff --git a/deploy/inventory/group_vars/all/vars.yml b/deploy/inventory/group_vars/all/vars.yml new file mode 100644 index 0000000..fa813c4 --- /dev/null +++ b/deploy/inventory/group_vars/all/vars.yml @@ -0,0 +1,10 @@ +--- +# WireGuard VPN peers — one entry per VPN peer. +# Generate keys on the admin workstation: +# wg genkey | tee private.key | wg pubkey > public.key +# wg genpsk > preshared.key # optional, post-quantum resistance +wireguard_peers: + - name: sysadmin + public_key: "" + allowed_ips: "10.8.0.2/32" + # preshared_key: "" # optional — for post-quantum resistance diff --git a/deploy/inventory/hosts.template b/deploy/inventory/hosts.template index e10e5d6..bd90c03 100644 --- a/deploy/inventory/hosts.template +++ b/deploy/inventory/hosts.template @@ -51,8 +51,8 @@ server_monitoring=munin app_monitoring=glowroot # WireGuard VPN — restricts Grafana, Prometheus, Munin, Glowroot and PostgreSQL to VPN-only access -# Define peers in group_vars/all.yml -# Override wireguard_port or wireguard_lockdown_monitoring in host_vars/group_vars if needed +# Define peers in group_vars/all/vars.yml +# Override wireguard_port or wireguard_lockdown_monitoring in host_vars or group_vars if needed wireguard_enabled=false From 8335973c844863361e2d77ffa3076479ac1d5d7a Mon Sep 17 00:00:00 2001 From: 0xafrogeek Date: Wed, 15 Apr 2026 23:17:32 +0000 Subject: [PATCH 07/14] fix: update PostgreSQL VPN access configuration to require SSL - Modified the pg_hba.conf entry for VPN users to use 'hostssl' instead of 'host', enforcing SSL connections for added security. - Updated documentation to reflect the change in access requirements, emphasizing the need for SSL even with VPN encryption. --- deploy/roles/wireguard/tasks/lockdown_postgres.yml | 4 ++-- docs/WireGuard-VPN.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/roles/wireguard/tasks/lockdown_postgres.yml b/deploy/roles/wireguard/tasks/lockdown_postgres.yml index f866584..7a30894 100644 --- a/deploy/roles/wireguard/tasks/lockdown_postgres.yml +++ b/deploy/roles/wireguard/tasks/lockdown_postgres.yml @@ -8,8 +8,8 @@ - name: Allow VPN admin access — add lxd_gateway_ip to pg_hba.conf (all databases, all users) ansible.builtin.lineinfile: path: '{{ pg_hba_path.stdout | trim }}' - regexp: "^host\\s+all\\s+all\\s+{{ (lxd_gateway_ip + '/32') | replace('.', '\\.') | replace('/', '\\/') }}" - line: 'host all all {{ lxd_gateway_ip }}/32 scram-sha-256' + regexp: "^(host|hostssl)\\s+all\\s+all\\s+{{ (lxd_gateway_ip + '/32') | replace('.', '\\.') | replace('/', '\\/') }}" + line: 'hostssl all all {{ lxd_gateway_ip }}/32 scram-sha-256' insertbefore: EOF notify: Reload PostgreSQL when: pg_hba_path.stdout | trim | length > 0 diff --git a/docs/WireGuard-VPN.md b/docs/WireGuard-VPN.md index d8c5ec2..6089181 100644 --- a/docs/WireGuard-VPN.md +++ b/docs/WireGuard-VPN.md @@ -184,7 +184,7 @@ When `wireguard_enabled=true` and `wireguard_lockdown_monitoring=true` (default) ### PostgreSQL VPN access -The VPN lockdown adds a `host all all /32 scram-sha-256` entry to `pg_hba.conf`. This grants VPN users access to all databases as any PostgreSQL user — intentionally broad to support ad-hoc admin access (`psql`) over the VPN. A password is still required (`scram-sha-256`). Application-level pg_hba entries (per-instance db/user restrictions) are managed separately by the `create-instance` role. +The VPN lockdown adds a `hostssl all all /32 scram-sha-256` entry to `pg_hba.conf`. This grants VPN users access to all databases as any PostgreSQL user — intentionally broad to support ad-hoc admin access (`psql`) over the VPN. SSL is required at the application layer (consistent with instance connections) for defense in depth, even though VPN traffic is already encrypted. A password is still required (`scram-sha-256`). Application-level pg_hba entries (per-instance db/user restrictions) are managed separately by the `create-instance` role. ### Restoring public monitoring access From dc425a00eee44b20feb2bfb8594c0796fe77b45d Mon Sep 17 00:00:00 2001 From: 0xafrogeek Date: Wed, 15 Apr 2026 23:26:26 +0000 Subject: [PATCH 08/14] feat: enhance instance configuration templates for Glowroot monitoring - Added conditional logic to Apache2 and Nginx instance templates to enable Glowroot monitoring only when not locked down by WireGuard settings. - Updated tasks to re-render instance configurations for both Apache2 and Nginx without Glowroot proxy blocks based on the defined conditions. --- .../templates/apache2/instance.j2 | 6 +++-- .../templates/nginx/instance.j2 | 6 +++-- .../roles/wireguard/tasks/lockdown_proxy.yml | 26 +++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/deploy/roles/create-instance/templates/apache2/instance.j2 b/deploy/roles/create-instance/templates/apache2/instance.j2 index 93f8fdf..fb16e8f 100644 --- a/deploy/roles/create-instance/templates/apache2/instance.j2 +++ b/deploy/roles/create-instance/templates/apache2/instance.j2 @@ -1,3 +1,5 @@ +{% set _glowroot_enabled = app_monitoring is defined and app_monitoring | trim == 'glowroot' %} +{% set _glowroot_locked = wireguard_enabled | default(false) | bool and wireguard_lockdown_monitoring | default(true) | bool %} {% if hostvars[item]['dhis2_base_path'] | default(item) | to_fixed_string == "ROOT" %} Require all granted @@ -5,7 +7,7 @@ ProxyPassReverse "http://{{ hostvars[item]['ansible_host']+':8080'}}/" -{% if app_monitoring is defined and app_monitoring | trim == 'glowroot' %} +{% if _glowroot_enabled and not _glowroot_locked %} Require all granted ProxyPass "http://{{ hostvars[item]['ansible_host']+':4000' }}/glowroot" @@ -19,7 +21,7 @@ ProxyPassReverse "http://{{ hostvars[item]['ansible_host']+':8080'}}/{{ hostvars[item]['dhis2_base_path'] | default(item) | to_fixed_string }}" -{% if app_monitoring is defined and app_monitoring | trim == 'glowroot' %} +{% if _glowroot_enabled and not _glowroot_locked %} Require all granted ProxyPass "http://{{ hostvars[item]['ansible_host']+':4000' }}/{{ hostvars[item]['dhis2_base_path'] | default(item) | to_fixed_string }}-glowroot" diff --git a/deploy/roles/create-instance/templates/nginx/instance.j2 b/deploy/roles/create-instance/templates/nginx/instance.j2 index e06e979..7690d2e 100644 --- a/deploy/roles/create-instance/templates/nginx/instance.j2 +++ b/deploy/roles/create-instance/templates/nginx/instance.j2 @@ -1,3 +1,5 @@ +{% set _glowroot_enabled = app_monitoring is defined and app_monitoring | trim == 'glowroot' %} +{% set _glowroot_locked = wireguard_enabled | default(false) | bool and wireguard_lockdown_monitoring | default(true) | bool %} {% if hostvars[item]['dhis2_base_path'] | default(item) | to_fixed_string == "ROOT" %} location / { proxy_pass http://{{hostvars[item]['ansible_host']+':8080' }}; @@ -10,7 +12,7 @@ location / { proxy_hide_header X-Powered-By; proxy_hide_header Server; } -{% if app_monitoring is defined and app_monitoring | trim == 'glowroot' %} +{% if _glowroot_enabled and not _glowroot_locked %} {# glowroot location block configs #} location /glowroot/ { proxy_pass http://{{ hostvars[item]['ansible_host']+':4000' }}/glowroot/; @@ -37,7 +39,7 @@ location /{{ hostvars[item]['dhis2_base_path'] | default(item) | to_fixed_string proxy_hide_header X-Powered-By; proxy_hide_header Server; } -{% if app_monitoring is defined and app_monitoring | trim == 'glowroot' %} +{% if _glowroot_enabled and not _glowroot_locked %} {# glowroot location block configs #} location /{{ hostvars[item]['dhis2_base_path'] | default(item) | to_fixed_string }}-glowroot { proxy_pass http://{{ hostvars[item]['ansible_host']+':4000' }}/{{ hostvars[item]['dhis2_base_path'] | default(item) | to_fixed_string }}-glowroot; diff --git a/deploy/roles/wireguard/tasks/lockdown_proxy.yml b/deploy/roles/wireguard/tasks/lockdown_proxy.yml index 2e74a65..d1bfb92 100644 --- a/deploy/roles/wireguard/tasks/lockdown_proxy.yml +++ b/deploy/roles/wireguard/tasks/lockdown_proxy.yml @@ -21,6 +21,19 @@ - proxy | default('nginx') == 'nginx' - nginx_monitoring_configs.files | default([]) | length > 0 +- name: Re-render nginx instance configs without Glowroot proxy blocks + ansible.builtin.template: + src: '{{ playbook_dir }}/roles/create-instance/templates/nginx/instance.j2' + dest: '/etc/nginx/conf.d/upstream/{{ item | to_fixed_string }}.conf' + owner: root + group: root + mode: '0640' + loop: '{{ groups["instances"] }}' + notify: Reload Nginx + when: + - proxy | default('nginx') == 'nginx' + - hostvars[item]['instance_state'] is undefined + - name: Find monitoring apache2 site configs ansible.builtin.find: paths: /etc/apache2/sites-enabled @@ -41,3 +54,16 @@ when: - proxy | default('nginx') == 'apache2' - apache_monitoring_configs.files | default([]) | length > 0 + +- name: Re-render apache2 instance configs without Glowroot proxy blocks + ansible.builtin.template: + src: '{{ playbook_dir }}/roles/create-instance/templates/apache2/instance.j2' + dest: '/etc/apache2/upstream/{{ item | to_fixed_string }}.conf' + owner: root + group: root + mode: '0640' + loop: '{{ groups["instances"] }}' + notify: Reload Apache2 + when: + - proxy | default('nginx') == 'apache2' + - hostvars[item]['instance_state'] is undefined From 4941c83a2cbe77eafa57209da67b6fb383e9b0b6 Mon Sep 17 00:00:00 2001 From: 0xafrogeek Date: Wed, 22 Apr 2026 19:20:45 +0000 Subject: [PATCH 09/14] feat: enhance WireGuard configuration with automatic key generation server-side - Introduced `wireguard_auto_generate_keys` and `wireguard_auto_generate_psk` options to allow server-side key generation for clients, simplifying peer configuration. - Updated validation tasks to ensure peers have required fields based on the key generation mode. - Enhanced documentation to clarify the new configuration options and their implications for client setup. - Added tasks for generating client keys and managing orphaned key files, improving overall management of WireGuard peers. --- deploy/inventory/group_vars/all/vars.yml | 12 +- deploy/roles/wireguard/defaults/main.yml | 6 + .../roles/wireguard/meta/argument_specs.yml | 36 +++- .../wireguard/tasks/generate_client_keys.yml | 173 ++++++++++++++++++ deploy/roles/wireguard/tasks/server.yml | 62 ++++--- deploy/roles/wireguard/tasks/validate.yml | 40 +++- .../roles/wireguard/templates/client.conf.j2 | 18 +- deploy/roles/wireguard/templates/wg0.conf.j2 | 6 +- docs/WireGuard-VPN.md | 141 ++++++++------ 9 files changed, 386 insertions(+), 108 deletions(-) create mode 100644 deploy/roles/wireguard/tasks/generate_client_keys.yml diff --git a/deploy/inventory/group_vars/all/vars.yml b/deploy/inventory/group_vars/all/vars.yml index fa813c4..b0d95a4 100644 --- a/deploy/inventory/group_vars/all/vars.yml +++ b/deploy/inventory/group_vars/all/vars.yml @@ -1,10 +1,12 @@ --- # WireGuard VPN peers — one entry per VPN peer. -# Generate keys on the admin workstation: -# wg genkey | tee private.key | wg pubkey > public.key -# wg genpsk > preshared.key # optional, post-quantum resistance +# With wireguard_auto_generate_keys: true (default), only name and allowed_ips +# are required. Keys are generated server-side automatically. +# +# To manage keys manually, set wireguard_auto_generate_keys: false and provide +# a public_key for each peer. wireguard_peers: - name: sysadmin - public_key: "" allowed_ips: "10.8.0.2/32" - # preshared_key: "" # optional — for post-quantum resistance + # public_key: "" # only needed if wireguard_auto_generate_keys: false + # preshared_key: "" # optional — for post-quantum resistance diff --git a/deploy/roles/wireguard/defaults/main.yml b/deploy/roles/wireguard/defaults/main.yml index f79bf38..9327b34 100644 --- a/deploy/roles/wireguard/defaults/main.yml +++ b/deploy/roles/wireguard/defaults/main.yml @@ -14,4 +14,10 @@ grafana_port: 3000 wireguard_peers: [] +wireguard_auto_generate_keys: true +wireguard_auto_generate_psk: false +wireguard_prune_orphans: false +wireguard_client_config_dir: /etc/wireguard/clients +wireguard_client_key_dir: /etc/wireguard/clients/keys + wireguard_lockdown_monitoring: true diff --git a/deploy/roles/wireguard/meta/argument_specs.yml b/deploy/roles/wireguard/meta/argument_specs.yml index 58b6abc..2a063af 100644 --- a/deploy/roles/wireguard/meta/argument_specs.yml +++ b/deploy/roles/wireguard/meta/argument_specs.yml @@ -40,11 +40,43 @@ argument_specs: default: "172.19.2.1" wireguard_peers: description: >- - List of VPN client peers. Each entry requires name, public_key, - and allowed_ips. Optional: preshared_key. + List of VPN client peers. Each entry requires name and allowed_ips. + public_key is required only when wireguard_auto_generate_keys is false. + Optional: preshared_key. type: list elements: dict default: [] + wireguard_auto_generate_keys: + description: >- + When true, generate client keypairs server-side and produce + complete, ready-to-import client configs. When false, peers + must supply their own public_key (current behavior). + type: bool + default: true + wireguard_auto_generate_psk: + description: >- + When true, generate a pre-shared key (PSK) for each peer that does + not supply an explicit preshared_key, and embed it into both the + server and client configs. Adds a symmetric post-quantum hedge. + Off by default — enabling on an existing deployment forces all + affected clients to re-import their .conf. + type: bool + default: false + wireguard_client_config_dir: + description: "Directory for generated client config files" + type: str + default: "/etc/wireguard/clients" + wireguard_client_key_dir: + description: "Directory for generated client key files" + type: str + default: "/etc/wireguard/clients/keys" + wireguard_prune_orphans: + description: >- + When true, remove client key/PSK/config files for peers no longer + present in wireguard_peers. Off by default to avoid accidental + destruction. + type: bool + default: false wireguard_lockdown_monitoring: description: >- When true, removes monitoring paths from the public proxy diff --git a/deploy/roles/wireguard/tasks/generate_client_keys.yml b/deploy/roles/wireguard/tasks/generate_client_keys.yml new file mode 100644 index 0000000..74b1439 --- /dev/null +++ b/deploy/roles/wireguard/tasks/generate_client_keys.yml @@ -0,0 +1,173 @@ +--- +- name: Ensure client key directory exists + ansible.builtin.file: + path: '{{ wireguard_client_key_dir }}' + state: directory + owner: root + group: root + mode: '0700' + +- name: Check for existing client private keys + ansible.builtin.stat: + path: '{{ wireguard_client_key_dir }}/{{ item.name }}.key' + loop: '{{ wireguard_peers }}' + loop_control: + label: '{{ item.name }}' + register: wireguard_client_key_stats + when: item.public_key is not defined + +- name: Generate client keypair (private + public) + ansible.builtin.shell: | + set -o pipefail + umask 077 + wg genkey | tee "{{ wireguard_client_key_dir }}/{{ item.item.name }}.key" | \ + wg pubkey > "{{ wireguard_client_key_dir }}/{{ item.item.name }}.pub" + args: + executable: /bin/bash + loop: '{{ wireguard_client_key_stats.results }}' + loop_control: + label: '{{ item.item.name }}' + when: + - item.stat is defined + - not item.stat.exists + changed_when: true + no_log: true + +- name: Read generated client public keys + ansible.builtin.slurp: + src: '{{ wireguard_client_key_dir }}/{{ item.name }}.pub' + loop: '{{ wireguard_peers }}' + loop_control: + label: '{{ item.name }}' + register: wireguard_client_public_keys + when: item.public_key is not defined + no_log: true + +- name: Read generated client private keys + ansible.builtin.slurp: + src: '{{ wireguard_client_key_dir }}/{{ item.name }}.key' + loop: '{{ wireguard_peers }}' + loop_control: + label: '{{ item.name }}' + register: wireguard_client_private_keys + when: item.public_key is not defined + no_log: true + +- name: Check for existing client PSKs + ansible.builtin.stat: + path: '{{ wireguard_client_key_dir }}/{{ item.name }}.psk' + loop: '{{ wireguard_peers }}' + loop_control: + label: '{{ item.name }}' + register: wireguard_client_psk_stats + when: + - wireguard_auto_generate_psk | bool + - item.preshared_key is not defined + +- name: Generate client PSK + ansible.builtin.shell: | + set -o pipefail + umask 077 + wg genpsk > "{{ wireguard_client_key_dir }}/{{ item.item.name }}.psk" + args: + executable: /bin/bash + loop: '{{ wireguard_client_psk_stats.results | default([]) }}' + loop_control: + label: "{{ item.item.name | default('(skipped)') }}" + when: + - wireguard_auto_generate_psk | bool + - item.stat is defined + - not item.stat.exists + changed_when: true + no_log: true + +- name: Read generated client PSKs + ansible.builtin.slurp: + src: '{{ wireguard_client_key_dir }}/{{ item.name }}.psk' + loop: '{{ wireguard_peers }}' + loop_control: + label: '{{ item.name }}' + register: wireguard_client_psks + when: + - wireguard_auto_generate_psk | bool + - item.preshared_key is not defined + no_log: true + +- name: Build enriched peer list with generated keys and PSKs + ansible.builtin.set_fact: + wireguard_peers_resolved: >- + {{ wireguard_peers_resolved | default([]) + [ + item | combine({ + 'public_key': (wireguard_client_public_keys.results[idx].content | b64decode | trim) + if item.public_key is not defined + else item.public_key, + '_private_key': (wireguard_client_private_keys.results[idx].content | b64decode | trim) + if item.public_key is not defined + else none, + '_preshared_key': item.preshared_key + if item.preshared_key is defined + else ((wireguard_client_psks.results[idx].content | b64decode | trim) + if (wireguard_auto_generate_psk | bool) + else none) + }) + ] }} + loop: '{{ wireguard_peers }}' + loop_control: + index_var: idx + label: '{{ item.name }}' + no_log: true + +- name: Find all client key files for permission enforcement + ansible.builtin.find: + paths: '{{ wireguard_client_key_dir }}' + patterns: '*.key,*.pub,*.psk' + register: wireguard_key_files_for_perms + +- name: Enforce 0600 on client key files + ansible.builtin.file: + path: '{{ item.path }}' + owner: root + group: root + mode: '0600' + loop: '{{ wireguard_key_files_for_perms.files | default([]) }}' + loop_control: + label: '{{ item.path | basename }}' + +- name: Find orphan client key files + ansible.builtin.find: + paths: '{{ wireguard_client_key_dir }}' + patterns: '*.key,*.pub,*.psk' + register: wireguard_existing_key_files + when: wireguard_prune_orphans | bool + +- name: Remove orphan client key files + ansible.builtin.file: + path: '{{ item.path }}' + state: absent + loop: '{{ wireguard_existing_key_files.files | default([]) }}' + loop_control: + label: '{{ item.path | basename }}' + when: + - wireguard_prune_orphans | bool + - (item.path | basename | regex_replace('\\.(key|pub|psk)$', '')) + not in (wireguard_peers | map(attribute='name') | list) + no_log: true + +- name: Find orphan client config files + ansible.builtin.find: + paths: '{{ wireguard_client_config_dir }}' + patterns: '*.conf' + register: wireguard_existing_conf_files + when: wireguard_prune_orphans | bool + +- name: Remove orphan client config files + ansible.builtin.file: + path: '{{ item.path }}' + state: absent + loop: '{{ wireguard_existing_conf_files.files | default([]) }}' + loop_control: + label: '{{ item.path | basename }}' + when: + - wireguard_prune_orphans | bool + - (item.path | basename | regex_replace('\\.conf$', '')) + not in (wireguard_peers | map(attribute='name') | list) diff --git a/deploy/roles/wireguard/tasks/server.yml b/deploy/roles/wireguard/tasks/server.yml index 7083546..5d383c6 100644 --- a/deploy/roles/wireguard/tasks/server.yml +++ b/deploy/roles/wireguard/tasks/server.yml @@ -2,8 +2,8 @@ - name: Gather network facts for client config endpoint fallback ansible.builtin.setup: gather_subset: - - "!all" - - "network" + - '!all' + - 'network' - name: Install WireGuard packages ansible.builtin.apt: @@ -20,15 +20,15 @@ state: directory owner: root group: root - mode: "0700" + mode: '0700' - name: Ensure clients config directory exists ansible.builtin.file: - path: /etc/wireguard/clients + path: '{{ wireguard_client_config_dir }}' state: directory owner: root group: root - mode: "0700" + mode: '0700' - name: Check if server private key exists ansible.builtin.stat: @@ -47,10 +47,10 @@ - name: Set restrictive permissions on key files ansible.builtin.file: - path: "/etc/wireguard/{{ item }}" + path: '/etc/wireguard/{{ item }}' owner: root group: root - mode: "0600" + mode: '0600' loop: - server_private.key - server_public.key @@ -72,26 +72,38 @@ WireGuard Server Public Key: {{ wg_server_public_key.content | b64decode | trim }} +# Required for routing VPN traffic into the LXD bridge. - name: Enable net.ipv4.ip_forward persistently ansible.posix.sysctl: name: net.ipv4.ip_forward - value: "1" + value: '1' state: present reload: true sysctl_set: true +- name: Generate client keys (auto-generate mode) + ansible.builtin.include_tasks: generate_client_keys.yml + when: + - wireguard_auto_generate_keys | bool + - wireguard_peers | length > 0 + +- name: Use provided peers when not auto-generating or no peers defined + ansible.builtin.set_fact: + wireguard_peers_resolved: '{{ wireguard_peers }}' + when: not (wireguard_auto_generate_keys | bool) or wireguard_peers | length == 0 + - name: Deploy WireGuard server configuration ansible.builtin.template: src: wg0.conf.j2 - dest: "/etc/wireguard/{{ wireguard_interface }}.conf" + dest: '/etc/wireguard/{{ wireguard_interface }}.conf' owner: root group: root - mode: "0600" + mode: '0600' notify: "{{ 'Sync WireGuard peers' if wg_private_key_stat.stat.exists else 'Restart WireGuard' }}" - name: Enable and start WireGuard service ansible.builtin.systemd: - name: "wg-quick@{{ wireguard_interface }}" + name: 'wg-quick@{{ wireguard_interface }}' state: started enabled: true daemon_reload: true @@ -106,20 +118,26 @@ - name: Generate client configuration files ansible.builtin.template: src: client.conf.j2 - dest: "/etc/wireguard/clients/{{ item.name }}.conf" + dest: '{{ wireguard_client_config_dir }}/{{ item.name }}.conf' owner: root group: root - mode: "0600" + mode: '0600' no_log: true - loop: "{{ wireguard_peers }}" + loop: '{{ wireguard_peers_resolved }}' loop_control: - label: "{{ item.name }}" - when: wireguard_peers | length > 0 + label: '{{ item.name }}' + when: item._private_key is defined and item._private_key | length > 0 -- name: Client config generation summary +- name: Display client retrieval instructions ansible.builtin.debug: - msg: >- - {{ wireguard_peers | length }} client config(s) generated in - /etc/wireguard/clients/. Each client must paste their own private key. - Generate QR codes with: qrencode -t ansiutf8 < /etc/wireguard/clients/.conf - when: wireguard_peers | length > 0 + msg: | + {{ wireguard_peers_resolved | length }} ready-to-use client config(s) generated. + + Retrieve configs: + sudo cat {{ wireguard_client_config_dir }}/.conf + + Copy to local machine: + scp @{{ fqdn or IP address of the server }}:{{ wireguard_client_config_dir }}/.conf . + when: + - wireguard_peers_resolved is defined + - wireguard_peers_resolved | length > 0 diff --git a/deploy/roles/wireguard/tasks/validate.yml b/deploy/roles/wireguard/tasks/validate.yml index b762e5a..727eec7 100644 --- a/deploy/roles/wireguard/tasks/validate.yml +++ b/deploy/roles/wireguard/tasks/validate.yml @@ -1,20 +1,50 @@ --- -- name: WireGuard | Assert each peer has required fields (name, public_key, allowed_ips) +# Pre-flight validation for wireguard_peers. +# Runs early to fail fast before template rendering produces cryptic WireGuard errors. + +- name: WireGuard | Assert each peer has required fields ansible.builtin.assert: that: - item.name is defined and item.name | length > 0 - - item.public_key is defined and item.public_key | length > 0 - item.allowed_ips is defined and item.allowed_ips | length > 0 + - item.public_key is defined or (wireguard_auto_generate_keys | bool) + fail_msg: >- + wireguard_peers[{{ idx }}] is missing required fields. + With auto-generate: name and allowed_ips required. + Without auto-generate: name, public_key, and allowed_ips required. + quiet: true + loop: "{{ wireguard_peers }}" + loop_control: + index_var: idx + label: "{{ item.name | default('UNNAMED-peer-' + idx | string) }}" + +- name: WireGuard | Assert peer names are filesystem-safe + ansible.builtin.assert: + that: + - item.name is match('^[a-zA-Z0-9._-]+$') fail_msg: >- - wireguard_peers[{{ idx }}] is missing a required field. - Each peer must define name, public_key, and allowed_ips. - Got: {{ item | dict2items | map(attribute='key') | list }} + wireguard_peers[{{ idx }}].name = '{{ item.name }}' contains + invalid characters. Allowed: letters, digits, dot, underscore, + hyphen. Required to prevent path traversal in client key/config + file paths. quiet: true loop: "{{ wireguard_peers }}" loop_control: index_var: idx label: "{{ item.name | default('UNNAMED-peer-' + idx | string) }}" +- name: WireGuard | Assert no duplicate peer names + ansible.builtin.assert: + that: + - wireguard_peers | map(attribute='name') | list | unique | length + == wireguard_peers | length + fail_msg: >- + Duplicate name detected in wireguard_peers. Names are used as + file paths for keys and configs — duplicates cause silent + overwrite. Values: {{ wireguard_peers | map(attribute='name') | list }} + quiet: true + when: wireguard_peers | length > 1 + - name: WireGuard | Assert no duplicate allowed_ips across peers ansible.builtin.assert: that: diff --git a/deploy/roles/wireguard/templates/client.conf.j2 b/deploy/roles/wireguard/templates/client.conf.j2 index 2c8e35a..c76abec 100644 --- a/deploy/roles/wireguard/templates/client.conf.j2 +++ b/deploy/roles/wireguard/templates/client.conf.j2 @@ -1,26 +1,16 @@ # WireGuard Client: {{ item.name }} -# Generated by Ansible — do not commit private keys to version control -# -# Import into WireGuard client: -# Linux: wg-quick up /path/to/{{ item.name }}.conf -# macOS: WireGuard app → Import tunnel(s) from file -# Windows: WireGuard app → Import tunnel(s) from file -# Mobile: qrencode -t ansiutf8 < {{ item.name }}.conf -# -# IMPORTANT: Replace with the private -# key that corresponds to the public key configured for this peer. +# Generated by Ansible — do not commit to version control [Interface] Address = {{ item.allowed_ips }} -PrivateKey = +PrivateKey = {{ item._private_key }} # DNS = 1.1.1.1, 8.8.8.8 [Peer] PublicKey = {{ wg_server_public_key.content | b64decode | trim }} -{% if item.preshared_key is defined %} -PresharedKey = {{ item.preshared_key }} +{% if item._preshared_key is defined and item._preshared_key %} +PresharedKey = {{ item._preshared_key }} {% endif %} Endpoint = {{ fqdn | default(ansible_default_ipv4.address, true) }}:{{ wireguard_port }} -# Split-tunnel: only VPN subnet + LXD container subnet route through tunnel. AllowedIPs = {{ wireguard_network }}, {{ lxd_network }} PersistentKeepalive = 25 diff --git a/deploy/roles/wireguard/templates/wg0.conf.j2 b/deploy/roles/wireguard/templates/wg0.conf.j2 index 7bfc9db..5037120 100644 --- a/deploy/roles/wireguard/templates/wg0.conf.j2 +++ b/deploy/roles/wireguard/templates/wg0.conf.j2 @@ -24,13 +24,13 @@ PostDown = iptables -t nat -D POSTROUTING -s {{ wireguard_network }} -o {{ lxd_b PostDown = ip6tables -D FORWARD -i %i -j DROP PostDown = ip6tables -D FORWARD -o %i -j DROP -{% for peer in wireguard_peers %} +{% for peer in wireguard_peers_resolved %} [Peer] # {{ peer.name }} PublicKey = {{ peer.public_key }} AllowedIPs = {{ peer.allowed_ips }} -{% if peer.preshared_key is defined %} -PresharedKey = {{ peer.preshared_key }} +{% if peer._preshared_key is defined and peer._preshared_key %} +PresharedKey = {{ peer._preshared_key }} {% endif %} {% endfor %} diff --git a/docs/WireGuard-VPN.md b/docs/WireGuard-VPN.md index 6089181..2f0ee71 100644 --- a/docs/WireGuard-VPN.md +++ b/docs/WireGuard-VPN.md @@ -40,25 +40,7 @@ WireGuard provides a secure VPN tunnel for administering DHIS2 infrastructure. W ## Quick Start -### 1. Generate Client Keys - -On your **admin workstation** (not the server): - -```bash -# Install WireGuard tools -sudo apt install wireguard-tools # Ubuntu/Debian -brew install wireguard-tools # macOS - -# Generate keypair -wg genkey | tee private.key | wg pubkey > public.key - -# Optional: generate a preshared key (post-quantum resistance) -wg genpsk > preshared.key -``` - -Keep `private.key` secure. You will need `public.key` (and optionally `preshared.key`) for the inventory configuration. - -### 2. Configure the Inventory +### 1. Configure the Inventory Edit `deploy/inventory/hosts` and set: @@ -67,25 +49,24 @@ Edit `deploy/inventory/hosts` and set: wireguard_enabled=true ``` -### 3. Define VPN Peers +### 2. Define VPN Peers Edit `deploy/inventory/group_vars/all/vars.yml`: ```yaml wireguard_peers: - - name: admin-alice - public_key: "paste-alice-public-key-here" + - name: sysadmin allowed_ips: "10.8.0.2/32" - preshared_key: "paste-preshared-key-here" # optional - name: admin-bob - public_key: "paste-bob-public-key-here" allowed_ips: "10.8.0.3/32" ``` +Each peer needs only a **name** and **IP address**. Client keys are generated automatically on the server. + Each peer must have a unique IP address within the `10.8.0.0/24` subnet. The server uses `10.8.0.1`; assign clients starting from `10.8.0.2`. -### 4. Deploy +### 3. Deploy ```bash cd deploy/ @@ -100,52 +81,38 @@ sudo ansible-playbook dhis2.yml --tags wireguard The playbook will: - Install WireGuard packages on the host - Generate server keypair (preserved across runs) +- Generate client keypairs server-side (preserved across runs) - Deploy the `wg0` interface configuration - Configure UFW and iptables forwarding rules - Lock down monitoring and database access to VPN-only -- Generate client configuration templates in `/etc/wireguard/clients/` - -### 5. Configure the Client +- Generate complete, ready-to-import client configs in `/etc/wireguard/clients/` -After deployment, the server displays its public key and generates client config files. - -**Retrieve the client config from the server:** +### 4. Retrieve and Import Client Config ```bash -sudo cat /etc/wireguard/clients/admin-alice.conf -``` - -The generated config looks like: +# View the config +sudo cat /etc/wireguard/clients/sysadmin.conf -```ini -[Interface] -Address = 10.8.0.2/32 -PrivateKey = -# DNS = 1.1.1.1, 8.8.8.8 - -[Peer] -PublicKey = -Endpoint = your-server.example.com:51820 -AllowedIPs = 10.8.0.0/24, 172.19.2.0/24 -PersistentKeepalive = 25 +# Copy to local machine +scp your-user@your-server:/etc/wireguard/clients/sysadmin.conf . ``` -**Edit the config** and replace `` with the private key you generated in step 1. +The config is complete — no editing needed. Import directly into your WireGuard client. **Connect:** ```bash # Linux -sudo wg-quick up /path/to/admin-alice.conf +sudo wg-quick up /path/to/sysadmin.conf # macOS / Windows # Import the .conf file into the WireGuard app # Mobile (generate QR code on the server) -sudo qrencode -t ansiutf8 < /etc/wireguard/clients/admin-alice.conf +sudo qrencode -t ansiutf8 < /etc/wireguard/clients/sysadmin.conf ``` -### 6. Verify Connectivity +### 5. Verify Connectivity ```bash # Check VPN interface is up @@ -184,7 +151,7 @@ When `wireguard_enabled=true` and `wireguard_lockdown_monitoring=true` (default) ### PostgreSQL VPN access -The VPN lockdown adds a `hostssl all all /32 scram-sha-256` entry to `pg_hba.conf`. This grants VPN users access to all databases as any PostgreSQL user — intentionally broad to support ad-hoc admin access (`psql`) over the VPN. SSL is required at the application layer (consistent with instance connections) for defense in depth, even though VPN traffic is already encrypted. A password is still required (`scram-sha-256`). Application-level pg_hba entries (per-instance db/user restrictions) are managed separately by the `create-instance` role. +The VPN lockdown adds a `host all all /32 scram-sha-256` entry to `pg_hba.conf`. This grants VPN users access to all databases as any PostgreSQL user — intentionally broad to support ad-hoc admin access (`psql`) over the VPN. A password is still required (`scram-sha-256`). Application-level pg_hba entries (per-instance db/user restrictions) are managed separately by the `create-instance` role. ### Restoring public monitoring access @@ -209,6 +176,11 @@ All variables are set in `deploy/roles/wireguard/defaults/main.yml` and can be o | `wireguard_server_ip` | `10.8.0.1` | Server address on the VPN | | `wireguard_port` | `51820` | UDP listen port | | `wireguard_interface` | `wg0` | WireGuard interface name | +| `wireguard_auto_generate_keys` | `true` | Generate client keypairs server-side | +| `wireguard_auto_generate_psk` | `false` | Auto-generate pre-shared keys for peers | +| `wireguard_client_config_dir` | `/etc/wireguard/clients` | Directory for client config files | +| `wireguard_client_key_dir` | `/etc/wireguard/clients/keys` | Directory for client key files | +| `wireguard_prune_orphans` | `false` | Remove files for peers no longer in inventory | | `lxd_bridge_interface` | `lxdbr1` | LXD bridge (must match your LXD network) | | `lxd_network` | `172.19.2.0/24` | LXD container subnet | | `lxd_gateway_ip` | `172.19.2.1` | Host-side IP of the LXD bridge | @@ -221,11 +193,27 @@ Each entry in `wireguard_peers` accepts: | Field | Required | Description | |---|---|---| -| `name` | Yes | Identifier (used for client config filename) | -| `public_key` | Yes | Client's WireGuard public key | +| `name` | Yes | Identifier — must be filesystem-safe (letters, digits, dot, underscore, hyphen) | | `allowed_ips` | Yes | Client's VPN IP (e.g., `10.8.0.2/32`) | +| `public_key` | No* | Client's WireGuard public key. *Required only when `wireguard_auto_generate_keys: false` | | `preshared_key` | No | Optional preshared key for post-quantum security | +### Key generation modes + +**Auto-generate (default):** `wireguard_auto_generate_keys: true` + +Only `name` and `allowed_ips` required per peer. The playbook generates client keypairs on the server and produces complete, ready-to-import `.conf` files. If a peer provides `public_key`, auto-generation is skipped for that peer (no client `.conf` emitted since the server never sees the private key). + +**Manual keys:** `wireguard_auto_generate_keys: false` + +Each peer must provide a `public_key`. This is the traditional workflow where admins generate keys on their workstation and paste the public key into inventory. + +### Pre-shared key (PSK) auto-generation + +Set `wireguard_auto_generate_psk: true` to generate a PSK for each peer that doesn't supply an explicit `preshared_key`. PSKs add a symmetric post-quantum hedge. + +> **Warning:** Enabling this on an existing deployment generates fresh PSKs on the next run for every peer that lacks an explicit `preshared_key`. All affected clients must re-import their `.conf`. + ## Split Tunneling The client configuration uses **split tunneling** by default: only traffic destined for the VPN subnet (`10.8.0.0/24`) and the LXD container subnet (`172.19.2.0/24`) routes through the tunnel. All other internet traffic uses the client's normal connection. @@ -244,14 +232,15 @@ AllowedIPs = 0.0.0.0/0 ### Adding a new peer -1. Generate keys on the new client's workstation. -2. Add the peer to `wireguard_peers` in your inventory. -3. Re-run the playbook: +1. Add the peer to `wireguard_peers` in your inventory with a unique name and IP. +2. Re-run the playbook: ```bash sudo ansible-playbook dhis2.yml --tags wireguard ``` +3. Retrieve the config from `/etc/wireguard/clients/.conf`. + The playbook uses `wg syncconf` to apply peer changes **without dropping existing VPN sessions**. ### Removing a peer @@ -263,7 +252,45 @@ The playbook uses `wg syncconf` to apply peer changes **without dropping existin sudo ansible-playbook dhis2.yml --tags wireguard ``` -The `wg syncconf` command removes peers that are no longer in the configuration. +The `wg syncconf` command removes peers that are no longer in the configuration. To also clean up orphaned key and config files, set `wireguard_prune_orphans: true`. + +### Rotating client keys + +Delete the client's key files on the server and re-run: + +```bash +# Rotate keys only +sudo rm /etc/wireguard/clients/keys/sysadmin.{key,pub} + +# Rotate PSK only +sudo rm /etc/wireguard/clients/keys/sysadmin.psk + +# Rotate both +sudo rm /etc/wireguard/clients/keys/sysadmin.{key,pub,psk} + +sudo ansible-playbook dhis2.yml --tags wireguard +``` + +The affected client must re-import their updated `.conf`. + +## File Layout on Server + +``` +/etc/wireguard/ +├── wg0.conf # Server config +├── server_private.key # Server private key +├── server_public.key # Server public key +└── clients/ + ├── user.conf # Complete, ready-to-import config + ├── sysadmin.conf + └── keys/ + ├── user.key # Client private key (0600) + ├── user.pub # Client public key (0600) + ├── user.psk # Client PSK (0600, only if auto_generate_psk=true) + ├── sysadmin.key + ├── sysadmin.pub + └── sysadmin.psk +``` ## Network Customization From 2a085f8f71e8cc8f5e5ff856b77933c157f774c6 Mon Sep 17 00:00:00 2001 From: 0xafrogeek Date: Wed, 22 Apr 2026 21:07:25 +0000 Subject: [PATCH 10/14] fix: update SCP command in WireGuard server configuration --- deploy/roles/wireguard/tasks/server.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/roles/wireguard/tasks/server.yml b/deploy/roles/wireguard/tasks/server.yml index 5d383c6..a8ebcdd 100644 --- a/deploy/roles/wireguard/tasks/server.yml +++ b/deploy/roles/wireguard/tasks/server.yml @@ -137,7 +137,7 @@ sudo cat {{ wireguard_client_config_dir }}/.conf Copy to local machine: - scp @{{ fqdn or IP address of the server }}:{{ wireguard_client_config_dir }}/.conf . + scp @{{ fqdn | default(ansible_default_ipv4.address, true) }}:{{ wireguard_client_config_dir }}/.conf . when: - wireguard_peers_resolved is defined - wireguard_peers_resolved | length > 0 From d3e56134065e4a1efe6621a2180e26539912a357 Mon Sep 17 00:00:00 2001 From: 0xafrogeek Date: Tue, 5 May 2026 08:51:04 +0000 Subject: [PATCH 11/14] feat: enhance WireGuard role with new hub and peer configurations - Introduced a dedicated LXD container for the WireGuard hub, improving isolation and management of VPN services. - Updated playbook to include tasks for provisioning the hub container, managing UDP port forwarding, and generating peer configurations. - Enhanced peer setup tasks to pull configurations from the hub, ensuring secure and consistent VPN access for app containers. - Refactored existing tasks to streamline the WireGuard role, removing deprecated server setup tasks and improving overall clarity and maintainability. --- deploy/dhis2.yml | 18 +- deploy/inventory/group_vars/all/vars.yml | 24 +- deploy/inventory/hosts.template | 29 +- deploy/roles/wireguard/defaults/main.yml | 28 +- .../roles/wireguard/meta/argument_specs.yml | 70 +++- deploy/roles/wireguard/tasks/firewall.yml | 25 -- .../wireguard/tasks/generate_client_keys.yml | 53 +-- .../wireguard/tasks/host_portforward.yml | 66 +++ deploy/roles/wireguard/tasks/hub.yml | 205 ++++++++++ .../wireguard/tasks/lockdown_instances.yml | 18 +- .../wireguard/tasks/lockdown_monitor.yml | 62 ++- .../wireguard/tasks/lockdown_postgres.yml | 52 ++- .../roles/wireguard/tasks/lockdown_proxy.yml | 12 +- .../roles/wireguard/tasks/lxd_container.yml | 55 +++ deploy/roles/wireguard/tasks/main.yml | 53 ++- deploy/roles/wireguard/tasks/peer.yml | 44 ++ deploy/roles/wireguard/tasks/server.yml | 143 ------- deploy/roles/wireguard/tasks/validate.yml | 122 +++++- .../roles/wireguard/templates/client.conf.j2 | 16 +- deploy/roles/wireguard/templates/wg0.conf.j2 | 27 +- docs/WireGuard-VPN.md | 378 ++++++++++-------- 21 files changed, 1027 insertions(+), 473 deletions(-) delete mode 100644 deploy/roles/wireguard/tasks/firewall.yml create mode 100644 deploy/roles/wireguard/tasks/host_portforward.yml create mode 100644 deploy/roles/wireguard/tasks/hub.yml create mode 100644 deploy/roles/wireguard/tasks/lxd_container.yml create mode 100644 deploy/roles/wireguard/tasks/peer.yml delete mode 100644 deploy/roles/wireguard/tasks/server.yml diff --git a/deploy/dhis2.yml b/deploy/dhis2.yml index e17537b..b28b943 100644 --- a/deploy/dhis2.yml +++ b/deploy/dhis2.yml @@ -2,17 +2,29 @@ - name: Preparing localhost hosts: 127.0.0.1 become: true - gather_facts: false + gather_facts: true roles: - role: pre-install - role: wireguard + vars: + wireguard_stage: host_portforward + tags: [wireguard] + +- name: WireGuard hub container + hosts: "{{ wireguard_hub_inventory_hostname | default('wireguard') }}" + become: true + gather_facts: false + roles: + - role: wireguard + vars: + wireguard_stage: hub tags: [wireguard] - name: DHIS2 setup gather_facts: false force_handlers: true become: true - hosts: all:!127.0.0.1 + hosts: "all:!127.0.0.1:!{{ wireguard_hub_inventory_hostname | default('wireguard') }}" vars_files: - vars/vars.yml roles: @@ -26,6 +38,8 @@ tags: [create-instance] when: instance_state is not defined or instance_state != 'deleted' - role: wireguard + vars: + wireguard_stage: peer tags: [wireguard] tasks: - name: Install and configure unattended_upgrades diff --git a/deploy/inventory/group_vars/all/vars.yml b/deploy/inventory/group_vars/all/vars.yml index b0d95a4..c4df056 100644 --- a/deploy/inventory/group_vars/all/vars.yml +++ b/deploy/inventory/group_vars/all/vars.yml @@ -1,12 +1,28 @@ --- -# WireGuard VPN peers — one entry per VPN peer. +# WireGuard human/admin peers (laptops, home machines, sysadmins). +# App containers (proxy/postgres/dhis/monitor) are auto-derived from the +# inventory's inline `wireguard_ip` and must NOT be listed here. +# The hub itself uses 10.0.0.1; assign human peers from 10.0.0.6 upward +# (10.0.0.2–.5 are reserved for app containers in the default inventory). +# # With wireguard_auto_generate_keys: true (default), only name and allowed_ips -# are required. Keys are generated server-side automatically. +# are required. Keys are generated hub-side automatically. # # To manage keys manually, set wireguard_auto_generate_keys: false and provide # a public_key for each peer. +# +# Optional pg_access: a list of {database, user} entries that produces per-peer +# pg_hba.conf rules on the databases host. Peers without pg_access have no +# PostgreSQL access. Database/user names must match ^[a-zA-Z0-9_]+$ (the +# PostgreSQL keyword 'all' is allowed). wireguard_peers: - name: sysadmin - allowed_ips: "10.8.0.2/32" + allowed_ips: "10.0.0.6/32" # public_key: "" # only needed if wireguard_auto_generate_keys: false - # preshared_key: "" # optional — for post-quantum resistance + # preshared_key: "" # optional — for post-quantum resistance + pg_access: + - { database: dhis, user: dhis } # change to { database: all, user: all } to allow access to all databases as any user + # - name: superuser + # allowed_ips: "10.0.0.7/32" + # pg_access: + # - { database: all, user: all } # superuser-equivalent - allows access to all databases as any user diff --git a/deploy/inventory/hosts.template b/deploy/inventory/hosts.template index bd90c03..211abe6 100644 --- a/deploy/inventory/hosts.template +++ b/deploy/inventory/hosts.template @@ -3,22 +3,27 @@ # proxy [web] -proxy ansible_host=172.19.2.2 +proxy ansible_host=172.19.2.2 wireguard_ip=10.0.0.2 # database hosts [databases] -postgres ansible_host=172.19.2.20 +postgres ansible_host=172.19.2.20 wireguard_ip=10.0.0.3 # dhis2 hosts [instances] -dhis ansible_host=172.19.2.11 database_host=postgres dhis2_version=2.42 proxy_rewrite=True +dhis ansible_host=172.19.2.11 database_host=postgres dhis2_version=2.42 proxy_rewrite=True wireguard_ip=10.0.0.4 # monitoring hosts [monitoring] -monitor ansible_host=172.19.2.30 +monitor ansible_host=172.19.2.30 wireguard_ip=10.0.0.5 + +# WireGuard hub container. wireguard_ip must match wireguard_server_ip. +# Group is wireguard_hub (not wireguard) to avoid Ansible's "host and group share name" warning. +[wireguard_hub] +wireguard ansible_host=172.19.2.200 wireguard_ip=10.0.0.1 [backup_servers] backup ansible_host=172.19.2.100 @@ -50,10 +55,20 @@ postgresql_version=16 server_monitoring=munin app_monitoring=glowroot -# WireGuard VPN — restricts Grafana, Prometheus, Munin, Glowroot and PostgreSQL to VPN-only access -# Define peers in group_vars/all/vars.yml -# Override wireguard_port or wireguard_lockdown_monitoring in host_vars or group_vars if needed +# WireGuard VPN — restricts Grafana, Prometheus, Munin, Glowroot and PostgreSQL to VPN-only access. +# App containers above are auto-derived as WG peers via inline wireguard_ip. +# Human/admin peers (laptops, home machines) live in group_vars/all/vars.yml. +# Hosts in groups other than [web]/[databases]/[instances]/[monitoring]/[wireguard] +# (e.g. [backup_servers], [integration]) are skipped — they don't need wireguard_ip. +# +# wireguard_endpoint_listen — host-side IP `lxc network forward` binds on. +# Empty = auto-detect via ansible_default_ipv4.address. Must be on the host. +# wireguard_endpoint_public — what home peers dial in their .conf Endpoint. +# Empty = same as wireguard_endpoint_listen. On cloud VMs with 1:1 NAT +# (AWS EIP, GCP external IP) you MUST set this to the public IP/hostname. wireguard_enabled=false +# wireguard_endpoint_listen= +# wireguard_endpoint_public= # lxd diff --git a/deploy/roles/wireguard/defaults/main.yml b/deploy/roles/wireguard/defaults/main.yml index 9327b34..17bd7d7 100644 --- a/deploy/roles/wireguard/defaults/main.yml +++ b/deploy/roles/wireguard/defaults/main.yml @@ -1,16 +1,32 @@ --- wireguard_enabled: false -wireguard_network: "10.8.0.0/24" -wireguard_server_ip: "10.8.0.1" +wireguard_network: "10.0.0.0/24" +wireguard_server_ip: "10.0.0.1" wireguard_port: 51820 wireguard_interface: wg0 -lxd_bridge_interface: "lxdbr1" -lxd_network: "172.19.2.0/24" -lxd_gateway_ip: "172.19.2.1" +# Hub container — dedicated LXD container running the WireGuard server. +wireguard_hub_inventory_hostname: "wireguard" +wireguard_hub_lxd_ip: "172.19.2.200" -grafana_port: 3000 +# Empty = auto-detect ansible_default_ipv4.address. On cloud VMs behind +# 1:1 NAT this is the private primary IP, not the public IP. +wireguard_endpoint_listen: "" + +# Falls back to wireguard_endpoint_listen when empty. Must be set explicitly +# on cloud VMs with 1:1 NAT or home peers receive an unroutable Endpoint. +wireguard_endpoint_public: "" + +wireguard_stage: "" + +# Cross-role shared variables consumed by proxy/postgres/monitoring/pre-install. +# Renaming with a wireguard_ prefix would break that contract. +lxd_bridge_interface: "lxdbr1" # noqa: var-naming[no-role-prefix] +lxd_network: "172.19.2.0/24" # noqa: var-naming[no-role-prefix] +lxd_gateway_ip: "172.19.2.1" # noqa: var-naming[no-role-prefix] + +grafana_port: 3000 # noqa: var-naming[no-role-prefix] wireguard_peers: [] diff --git a/deploy/roles/wireguard/meta/argument_specs.yml b/deploy/roles/wireguard/meta/argument_specs.yml index 2a063af..3796cdb 100644 --- a/deploy/roles/wireguard/meta/argument_specs.yml +++ b/deploy/roles/wireguard/meta/argument_specs.yml @@ -3,21 +3,56 @@ argument_specs: main: short_description: "WireGuard VPN for securing DHIS2 monitoring and database access" description: - - Installs and configures a WireGuard VPN server on the host machine. + - Deploys a WireGuard VPN hub inside a dedicated LXD container. + - Each app container (proxy/postgres/dhis/monitor) joins as a WG peer. - Optionally locks down monitoring dashboards and PostgreSQL to VPN-only access. options: wireguard_enabled: description: "Master switch — enable the WireGuard VPN role" type: bool default: false + wireguard_stage: + description: >- + Per-play stage selector. Set by dhis2.yml when including the role. + One of: host_portforward (LXD host: provision wireguard container + + UDP 51820 forward), hub (the wireguard container itself), peer + (every app container). + type: str + default: "" + choices: ["", "host_portforward", "hub", "peer"] wireguard_network: description: "VPN subnet in CIDR notation" type: str - default: "10.8.0.0/24" + default: "10.0.0.0/24" wireguard_server_ip: - description: "Server IP address on the VPN subnet" + description: "Hub IP address on the VPN subnet" + type: str + default: "10.0.0.1" + wireguard_hub_inventory_hostname: + description: "Inventory hostname of the wireguard hub container" + type: str + default: "wireguard" + wireguard_hub_lxd_ip: + description: "Static LXD IP assigned to the wireguard hub container" + type: str + default: "172.19.2.200" + wireguard_endpoint_listen: + description: >- + Listen address used by `lxc network forward` on the LXD host. + Must be an IP owned by the host itself. Empty = auto-detect via + ansible_default_ipv4.address. On cloud VMs behind 1:1 NAT this + is the private primary IP, NOT the public IP. + type: str + default: "" + wireguard_endpoint_public: + description: >- + Public IP/hostname advertised to home/admin peers as their + Endpoint = line. Empty falls back to wireguard_endpoint_listen. + On cloud VMs with 1:1 NAT this MUST be set explicitly to the + public IP/hostname or home peers will receive an unroutable + Endpoint. type: str - default: "10.8.0.1" + default: "" wireguard_port: description: "UDP listen port for WireGuard" type: int @@ -40,41 +75,50 @@ argument_specs: default: "172.19.2.1" wireguard_peers: description: >- - List of VPN client peers. Each entry requires name and allowed_ips. + List of human/admin VPN peers (laptops, home machines). + App containers are auto-derived from inventory wireguard_ip; do NOT + list them here. Each entry requires name and allowed_ips. public_key is required only when wireguard_auto_generate_keys is false. Optional: preshared_key. + Optional: peer_ip (single CIDR ending in /32) — used for per-peer + pg_hba.conf rules when allowed_ips routes additional networks. If + omitted, the first CIDR in allowed_ips is used. + Optional: pg_access (list of {database, user}) — generates per-peer + pg_hba.conf entries on the databases host. Database and user names + must match ^[a-zA-Z0-9_]+$ (PostgreSQL keyword 'all' is allowed). + Peers without pg_access have no PostgreSQL access. type: list elements: dict default: [] wireguard_auto_generate_keys: description: >- - When true, generate client keypairs server-side and produce + When true, generate peer keypairs hub-side and produce complete, ready-to-import client configs. When false, peers - must supply their own public_key (current behavior). + must supply their own public_key. type: bool default: true wireguard_auto_generate_psk: description: >- When true, generate a pre-shared key (PSK) for each peer that does not supply an explicit preshared_key, and embed it into both the - server and client configs. Adds a symmetric post-quantum hedge. + hub and peer configs. Adds a symmetric post-quantum hedge. Off by default — enabling on an existing deployment forces all affected clients to re-import their .conf. type: bool default: false wireguard_client_config_dir: - description: "Directory for generated client config files" + description: "Directory on the hub for generated peer config files" type: str default: "/etc/wireguard/clients" wireguard_client_key_dir: - description: "Directory for generated client key files" + description: "Directory on the hub for generated peer key files" type: str default: "/etc/wireguard/clients/keys" wireguard_prune_orphans: description: >- - When true, remove client key/PSK/config files for peers no longer - present in wireguard_peers. Off by default to avoid accidental - destruction. + When true, remove peer key/PSK/config files for peers no longer + present in inventory or wireguard_peers. Off by default to avoid + accidental destruction. type: bool default: false wireguard_lockdown_monitoring: diff --git a/deploy/roles/wireguard/tasks/firewall.yml b/deploy/roles/wireguard/tasks/firewall.yml deleted file mode 100644 index 91420c0..0000000 --- a/deploy/roles/wireguard/tasks/firewall.yml +++ /dev/null @@ -1,25 +0,0 @@ ---- -- name: Allow WireGuard UDP port {{ wireguard_port }} - community.general.ufw: - rule: allow - port: "{{ wireguard_port | string }}" - proto: udp - comment: "WireGuard VPN" - -- name: Allow all traffic from VPN interface - community.general.ufw: - rule: allow - direction: in - interface: "{{ wireguard_interface }}" - comment: "Allow all VPN client traffic on {{ wireguard_interface }}" - -- name: Add VPN↔LXD forwarding rules to /etc/ufw/before.rules - ansible.builtin.blockinfile: - path: /etc/ufw/before.rules - marker: "# {mark} WIREGUARD VPN FORWARDING" - insertafter: "^-A ufw-before-forward" - block: | - # Allow forwarding between WireGuard VPN and LXD containers - -A ufw-before-forward -i {{ wireguard_interface }} -o {{ lxd_bridge_interface }} -s {{ wireguard_network }} -d {{ lxd_network }} -j ACCEPT - -A ufw-before-forward -i {{ lxd_bridge_interface }} -o {{ wireguard_interface }} -s {{ lxd_network }} -d {{ wireguard_network }} -j ACCEPT - notify: Reload UFW diff --git a/deploy/roles/wireguard/tasks/generate_client_keys.yml b/deploy/roles/wireguard/tasks/generate_client_keys.yml index 74b1439..eba396d 100644 --- a/deploy/roles/wireguard/tasks/generate_client_keys.yml +++ b/deploy/roles/wireguard/tasks/generate_client_keys.yml @@ -1,5 +1,11 @@ --- -- name: Ensure client key directory exists +# Hub-side keypair + PSK generator. +# Caller passes `wg_keygen_peers` — a list of normalized peer dicts (name, +# allowed_ips, wireguard_ip, endpoint_kind, optional public_key/preshared_key). +# Produces wireguard_peers_resolved enriched with public_key, _private_key, +# and _preshared_key per entry. + +- name: KeyGen | Ensure client key directory exists ansible.builtin.file: path: '{{ wireguard_client_key_dir }}' state: directory @@ -7,16 +13,16 @@ group: root mode: '0700' -- name: Check for existing client private keys +- name: KeyGen | Check for existing peer private keys ansible.builtin.stat: path: '{{ wireguard_client_key_dir }}/{{ item.name }}.key' - loop: '{{ wireguard_peers }}' + loop: '{{ wg_keygen_peers }}' loop_control: label: '{{ item.name }}' register: wireguard_client_key_stats when: item.public_key is not defined -- name: Generate client keypair (private + public) +- name: KeyGen | Generate peer keypair (private + public) ansible.builtin.shell: | set -o pipefail umask 077 @@ -33,30 +39,30 @@ changed_when: true no_log: true -- name: Read generated client public keys +- name: KeyGen | Read generated peer public keys ansible.builtin.slurp: src: '{{ wireguard_client_key_dir }}/{{ item.name }}.pub' - loop: '{{ wireguard_peers }}' + loop: '{{ wg_keygen_peers }}' loop_control: label: '{{ item.name }}' register: wireguard_client_public_keys when: item.public_key is not defined no_log: true -- name: Read generated client private keys +- name: KeyGen | Read generated peer private keys ansible.builtin.slurp: src: '{{ wireguard_client_key_dir }}/{{ item.name }}.key' - loop: '{{ wireguard_peers }}' + loop: '{{ wg_keygen_peers }}' loop_control: label: '{{ item.name }}' register: wireguard_client_private_keys when: item.public_key is not defined no_log: true -- name: Check for existing client PSKs +- name: KeyGen | Check for existing peer PSKs ansible.builtin.stat: path: '{{ wireguard_client_key_dir }}/{{ item.name }}.psk' - loop: '{{ wireguard_peers }}' + loop: '{{ wg_keygen_peers }}' loop_control: label: '{{ item.name }}' register: wireguard_client_psk_stats @@ -64,7 +70,7 @@ - wireguard_auto_generate_psk | bool - item.preshared_key is not defined -- name: Generate client PSK +- name: KeyGen | Generate peer PSK ansible.builtin.shell: | set -o pipefail umask 077 @@ -81,10 +87,10 @@ changed_when: true no_log: true -- name: Read generated client PSKs +- name: KeyGen | Read generated peer PSKs ansible.builtin.slurp: src: '{{ wireguard_client_key_dir }}/{{ item.name }}.psk' - loop: '{{ wireguard_peers }}' + loop: '{{ wg_keygen_peers }}' loop_control: label: '{{ item.name }}' register: wireguard_client_psks @@ -93,7 +99,7 @@ - item.preshared_key is not defined no_log: true -- name: Build enriched peer list with generated keys and PSKs +- name: KeyGen | Build wireguard_peers_resolved with keys and PSKs ansible.builtin.set_fact: wireguard_peers_resolved: >- {{ wireguard_peers_resolved | default([]) + [ @@ -111,19 +117,19 @@ else none) }) ] }} - loop: '{{ wireguard_peers }}' + loop: '{{ wg_keygen_peers }}' loop_control: index_var: idx label: '{{ item.name }}' no_log: true -- name: Find all client key files for permission enforcement +- name: KeyGen | Find all peer key files for permission enforcement ansible.builtin.find: paths: '{{ wireguard_client_key_dir }}' patterns: '*.key,*.pub,*.psk' register: wireguard_key_files_for_perms -- name: Enforce 0600 on client key files +- name: KeyGen | Enforce 0600 on peer key files ansible.builtin.file: path: '{{ item.path }}' owner: root @@ -133,14 +139,14 @@ loop_control: label: '{{ item.path | basename }}' -- name: Find orphan client key files +- name: KeyGen | Find orphan peer key files ansible.builtin.find: paths: '{{ wireguard_client_key_dir }}' patterns: '*.key,*.pub,*.psk' register: wireguard_existing_key_files when: wireguard_prune_orphans | bool -- name: Remove orphan client key files +- name: KeyGen | Remove orphan peer key files ansible.builtin.file: path: '{{ item.path }}' state: absent @@ -150,17 +156,17 @@ when: - wireguard_prune_orphans | bool - (item.path | basename | regex_replace('\\.(key|pub|psk)$', '')) - not in (wireguard_peers | map(attribute='name') | list) + not in (wg_keygen_peers | map(attribute='name') | list) no_log: true -- name: Find orphan client config files +- name: KeyGen | Find orphan peer config files ansible.builtin.find: paths: '{{ wireguard_client_config_dir }}' patterns: '*.conf' register: wireguard_existing_conf_files when: wireguard_prune_orphans | bool -- name: Remove orphan client config files +- name: KeyGen | Remove orphan peer config files ansible.builtin.file: path: '{{ item.path }}' state: absent @@ -170,4 +176,5 @@ when: - wireguard_prune_orphans | bool - (item.path | basename | regex_replace('\\.conf$', '')) - not in (wireguard_peers | map(attribute='name') | list) + not in (wg_keygen_peers | map(attribute='name') | list) + no_log: true diff --git a/deploy/roles/wireguard/tasks/host_portforward.yml b/deploy/roles/wireguard/tasks/host_portforward.yml new file mode 100644 index 0000000..eb04863 --- /dev/null +++ b/deploy/roles/wireguard/tasks/host_portforward.yml @@ -0,0 +1,66 @@ +--- +# Forwards UDP {{ wireguard_port }} from a host-owned IP into the hub +# container via `lxc network forward`. +# +# wireguard_endpoint_listen is the host-side bind IP (LXD forwards bind on +# host-side only). wireguard_endpoint_public is what home peers dial; it's +# resolved in hub.yml. On cloud VMs with 1:1 NAT (e.g. AWS EIP) the listen +# IP is the private primary and public IP must be set explicitly. + +- name: WireGuard | Resolve listen address for `lxc network forward` + ansible.builtin.set_fact: + wireguard_endpoint_listen_resolved: >- + {{ wireguard_endpoint_listen + if (wireguard_endpoint_listen | length > 0) + else ansible_default_ipv4.address }} + +- name: WireGuard | Resolve public endpoint advertised to home peers + ansible.builtin.set_fact: + wireguard_endpoint_public_resolved: >- + {{ wireguard_endpoint_public + if (wireguard_endpoint_public | length > 0) + else wireguard_endpoint_listen_resolved }} + +- name: WireGuard | List existing LXD network forwards + vars: + ansible_connection: local + ansible.builtin.command: + cmd: lxc network forward list {{ lxd_bridge_interface }} --format json + register: wireguard_lxd_forwards + changed_when: false + +- name: WireGuard | Ensure LXD network forward exists for listen address + vars: + ansible_connection: local + _listen_addrs: >- + {{ wireguard_lxd_forwards.stdout | from_json | map(attribute='listen_address') | list }} + ansible.builtin.command: + cmd: >- + lxc network forward create {{ lxd_bridge_interface }} + {{ wireguard_endpoint_listen_resolved }} + changed_when: true + when: wireguard_endpoint_listen_resolved not in _listen_addrs + +# lxc network forward DNATs in PREROUTING — packets go to the container +# via FORWARD chain, never hit the host INPUT chain. No host UFW rule needed. +- name: WireGuard | Add UDP forward to hub container + vars: + ansible_connection: local + ansible.builtin.command: + cmd: >- + lxc network forward port add {{ lxd_bridge_interface }} + {{ wireguard_endpoint_listen_resolved }} udp {{ wireguard_port }} + {{ wireguard_hub_lxd_ip }} {{ wireguard_port }} + register: wireguard_port_fwd + changed_when: wireguard_port_fwd.rc == 0 + failed_when: + - wireguard_port_fwd.rc != 0 + - "'already exists' not in wireguard_port_fwd.stderr" + - "'Duplicate' not in wireguard_port_fwd.stderr" + +- name: WireGuard | Remove legacy host UFW UDP rule (no longer needed with lxc forward) + community.general.ufw: + rule: allow + port: "{{ wireguard_port | string }}" + proto: udp + delete: true diff --git a/deploy/roles/wireguard/tasks/hub.yml b/deploy/roles/wireguard/tasks/hub.yml new file mode 100644 index 0000000..60cbc5e --- /dev/null +++ b/deploy/roles/wireguard/tasks/hub.yml @@ -0,0 +1,205 @@ +--- +- name: Hub | Install WireGuard packages + ansible.builtin.apt: + name: + - wireguard + - wireguard-tools + state: present + update_cache: true + cache_valid_time: 3600 + +- name: Hub | Ensure /etc/wireguard exists with secure permissions + ansible.builtin.file: + path: /etc/wireguard + state: directory + owner: root + group: root + mode: '0700' + +- name: Hub | Ensure clients config directory exists + ansible.builtin.file: + path: '{{ wireguard_client_config_dir }}' + state: directory + owner: root + group: root + mode: '0700' + +- name: Hub | Check if hub private key exists + ansible.builtin.stat: + path: /etc/wireguard/server_private.key + register: wireguard_private_key_stat + +- name: Hub | Generate hub keypair + ansible.builtin.shell: | + set -o pipefail + umask 077 + wg genkey | tee /etc/wireguard/server_private.key | wg pubkey > /etc/wireguard/server_public.key + args: + executable: /bin/bash + when: not wireguard_private_key_stat.stat.exists + changed_when: true + +- name: Hub | Set restrictive permissions on hub key files + ansible.builtin.file: + path: '/etc/wireguard/{{ item }}' + owner: root + group: root + mode: '0600' + loop: + - server_private.key + - server_public.key + +- name: Hub | Read hub private key + ansible.builtin.slurp: + src: /etc/wireguard/server_private.key + register: wireguard_server_private_key_data + no_log: true + +- name: Hub | Read hub public key + ansible.builtin.slurp: + src: /etc/wireguard/server_public.key + register: wireguard_server_public_key_data + +- name: Hub | Display hub public key + ansible.builtin.debug: + msg: >- + WireGuard Hub Public Key: + {{ wireguard_server_public_key_data.content | b64decode | trim }} + +- name: Hub | Enable IPv4 forwarding (spoke ↔ spoke routing) + ansible.posix.sysctl: + name: net.ipv4.ip_forward + value: '1' + state: present + reload: true + sysctl_set: true + +- name: Hub | Allow wg0 ↔ wg0 FORWARD (mesh relay) + ansible.builtin.iptables: + chain: FORWARD + in_interface: '{{ wireguard_interface }}' + out_interface: '{{ wireguard_interface }}' + jump: ACCEPT + state: present + +# Normalize app-container and human peers into one shape so downstream +# templates and key generation can iterate uniformly. +- name: Hub | Build app-container peer entries from inventory + ansible.builtin.set_fact: + _wireguard_app_peers_normalized: >- + {{ _wireguard_app_peers_normalized | default([]) + [ + { + 'name': item, + 'allowed_ips': hostvars[item].wireguard_ip ~ '/32', + 'wireguard_ip': hostvars[item].wireguard_ip, + 'endpoint_kind': 'internal' + } + ] }} + loop: "{{ groups['all'] | difference(['127.0.0.1', wireguard_hub_inventory_hostname]) }}" + loop_control: + label: "{{ item }}" + when: hostvars[item].wireguard_ip is defined + +- name: Hub | Normalize human peers from wireguard_peers + ansible.builtin.set_fact: + _wireguard_human_peers_normalized: >- + {{ _wireguard_human_peers_normalized | default([]) + [ + item | combine({ + 'wireguard_ip': item.allowed_ips.split(',')[0] | trim | regex_replace('/[0-9]+$', ''), + 'endpoint_kind': 'external' + }) + ] }} + loop: "{{ wireguard_peers }}" + loop_control: + label: "{{ item.name }}" + +- name: Hub | Combine app + human peers into unified list + ansible.builtin.set_fact: + _wireguard_unified_peers: >- + {{ (_wireguard_app_peers_normalized | default([])) + + (_wireguard_human_peers_normalized | default([])) }} + +- name: Hub | Resolve public endpoint for home/admin peers + ansible.builtin.set_fact: + wireguard_endpoint_public_resolved: >- + {{ wireguard_endpoint_public + if (wireguard_endpoint_public | length > 0) + else hostvars['127.0.0.1']['wireguard_endpoint_public_resolved'] + | default(hostvars['127.0.0.1']['wireguard_endpoint_listen_resolved'] + | default(hostvars['127.0.0.1']['ansible_default_ipv4']['address'] + | default('127.0.0.1'))) }} + +- name: Hub | Generate keys for all peers (auto-generate mode) + ansible.builtin.include_tasks: generate_client_keys.yml + vars: + wg_keygen_peers: "{{ _wireguard_unified_peers }}" + when: + - wireguard_auto_generate_keys | bool + - _wireguard_unified_peers | length > 0 + +- name: Hub | Use unified peer list as resolved (no auto-generate) + ansible.builtin.set_fact: + wireguard_peers_resolved: '{{ _wireguard_unified_peers }}' + when: not (wireguard_auto_generate_keys | bool) or _wireguard_unified_peers | length == 0 + +# Two render paths so live VPN sessions survive peer changes: first run +# starts fresh from the file; subsequent runs notify `wg syncconf` instead +# of restarting the unit. +- name: Hub | Render wg0.conf + ansible.builtin.template: + src: wg0.conf.j2 + dest: '/etc/wireguard/{{ wireguard_interface }}.conf' + owner: root + group: root + mode: '0600' + register: wireguard_hub_conf + notify: Sync WireGuard peers + when: wireguard_private_key_stat.stat.exists + +- name: Hub | Render wg0.conf (first run) + ansible.builtin.template: + src: wg0.conf.j2 + dest: '/etc/wireguard/{{ wireguard_interface }}.conf' + owner: root + group: root + mode: '0600' + when: not wireguard_private_key_stat.stat.exists + +- name: Hub | Enable and start WireGuard service + ansible.builtin.systemd: + name: 'wg-quick@{{ wireguard_interface }}' + state: started + enabled: true + daemon_reload: true + +- name: Hub | Warn when no peers are configured + ansible.builtin.debug: + msg: >- + WARNING: no WireGuard peers found (no app containers with wireguard_ip + and no entries in wireguard_peers). The hub will start but nothing can + connect. + when: _wireguard_unified_peers | length == 0 + +- name: Hub | Render per-peer client configs + ansible.builtin.template: + src: client.conf.j2 + dest: '{{ wireguard_client_config_dir }}/{{ item.name }}.conf' + owner: root + group: root + mode: '0600' + no_log: true + loop: '{{ wireguard_peers_resolved }}' + loop_control: + label: '{{ item.name }} ({{ item.endpoint_kind }})' + when: item._private_key is defined and item._private_key | length > 0 + +- name: Hub | Display peer config retrieval instructions + ansible.builtin.debug: + msg: | + {{ wireguard_peers_resolved | length }} peer config(s) generated on the hub. + + App-container configs are pulled automatically by the peer stage. + + Human peer configs ({{ _wireguard_human_peers_normalized | default([]) | length }}): + sudo lxc exec {{ wireguard_hub_inventory_hostname }} -- cat {{ wireguard_client_config_dir }}/.conf + when: wireguard_peers_resolved | default([]) | length > 0 diff --git a/deploy/roles/wireguard/tasks/lockdown_instances.yml b/deploy/roles/wireguard/tasks/lockdown_instances.yml index a5582f2..9aff3a0 100644 --- a/deploy/roles/wireguard/tasks/lockdown_instances.yml +++ b/deploy/roles/wireguard/tasks/lockdown_instances.yml @@ -2,20 +2,28 @@ - name: Check if munin-node is installed ansible.builtin.stat: path: /etc/munin/munin-node.conf - register: munin_node_stat + register: wireguard_munin_node_stat -- name: Allow Glowroot (4000/tcp) from LXD gateway only +- name: Allow Glowroot (4000/tcp) from VPN subnet community.general.ufw: rule: allow port: '4000' - src: '{{ lxd_gateway_ip }}' + src: '{{ wireguard_network }}' proto: tcp state: enabled - comment: 'Glowroot APM — VPN access only (via LXD gateway NAT)' + comment: 'Glowroot APM — VPN access only' when: - app_monitoring is defined - app_monitoring | trim == 'glowroot' +- name: Lockdown | Remove legacy Glowroot UFW rule (src=lxd_gateway_ip) + community.general.ufw: + rule: allow + port: '4000' + src: '{{ lxd_gateway_ip }}' + proto: tcp + delete: true + - name: Allow munin-node (4949/tcp) from monitor container only community.general.ufw: rule: allow @@ -25,5 +33,5 @@ state: enabled comment: 'munin-node — monitor container access only' when: - - munin_node_stat.stat.exists + - wireguard_munin_node_stat.stat.exists - groups.get('monitoring', []) | length > 0 diff --git a/deploy/roles/wireguard/tasks/lockdown_monitor.yml b/deploy/roles/wireguard/tasks/lockdown_monitor.yml index 40d22fc..efd294c 100644 --- a/deploy/roles/wireguard/tasks/lockdown_monitor.yml +++ b/deploy/roles/wireguard/tasks/lockdown_monitor.yml @@ -2,7 +2,7 @@ - name: Check if Grafana is installed ansible.builtin.stat: path: /etc/grafana/grafana.ini - register: grafana_ini_stat + register: wireguard_grafana_ini_stat - name: Reset Grafana root_url for direct access community.general.ini_file: @@ -14,7 +14,7 @@ owner: root group: grafana notify: Restart Grafana - when: grafana_ini_stat.stat.exists + when: wireguard_grafana_ini_stat.stat.exists - name: Disable Grafana serve_from_sub_path community.general.ini_file: @@ -26,7 +26,7 @@ owner: root group: grafana notify: Restart Grafana - when: grafana_ini_stat.stat.exists + when: wireguard_grafana_ini_stat.stat.exists - name: Ensure Grafana listens on all container interfaces community.general.ini_file: @@ -38,22 +38,30 @@ owner: root group: grafana notify: Restart Grafana - when: grafana_ini_stat.stat.exists + when: wireguard_grafana_ini_stat.stat.exists - name: Check if Prometheus is installed ansible.builtin.stat: path: /etc/prometheus/prometheus.yml - register: prometheus_stat + register: wireguard_prometheus_stat -- name: Allow Grafana (3000/tcp) from LXD gateway only +- name: Allow Grafana from VPN subnet community.general.ufw: rule: allow port: "{{ grafana_port }}" - src: '{{ lxd_gateway_ip }}' + src: '{{ wireguard_network }}' proto: tcp state: enabled - comment: 'Grafana — VPN access only (via LXD gateway NAT)' - when: grafana_ini_stat.stat.exists + comment: 'Grafana — VPN access only' + when: wireguard_grafana_ini_stat.stat.exists + +- name: Lockdown | Remove legacy Grafana UFW rule (src=lxd_gateway_ip) + community.general.ufw: + rule: allow + port: "{{ grafana_port }}" + src: '{{ lxd_gateway_ip }}' + proto: tcp + delete: true - name: Remove proxy -> Grafana UFW rule community.general.ufw: @@ -65,29 +73,45 @@ loop: "{{ groups.get('web', []) }}" loop_control: label: '{{ item }}' - when: grafana_ini_stat.stat.exists + when: wireguard_grafana_ini_stat.stat.exists -- name: Allow Prometheus (9090/tcp) from LXD gateway only +- name: Allow Prometheus (9090/tcp) from VPN subnet community.general.ufw: rule: allow port: '9090' - src: '{{ lxd_gateway_ip }}' + src: '{{ wireguard_network }}' proto: tcp state: enabled - comment: 'Prometheus — VPN access only (via LXD gateway NAT)' - when: prometheus_stat.stat.exists + comment: 'Prometheus — VPN access only' + when: wireguard_prometheus_stat.stat.exists + +- name: Lockdown | Remove legacy Prometheus UFW rule (src=lxd_gateway_ip) + community.general.ufw: + rule: allow + port: '9090' + src: '{{ lxd_gateway_ip }}' + proto: tcp + delete: true - name: Check if Munin is installed ansible.builtin.stat: path: /etc/munin/munin.conf - register: munin_conf_stat + register: wireguard_munin_conf_stat -- name: Allow Munin (80/tcp) from LXD gateway only +- name: Allow Munin (80/tcp) from VPN subnet community.general.ufw: rule: allow port: '80' - src: '{{ lxd_gateway_ip }}' + src: '{{ wireguard_network }}' proto: tcp state: enabled - comment: 'Munin — VPN access only (via LXD gateway NAT)' - when: munin_conf_stat.stat.exists + comment: 'Munin — VPN access only' + when: wireguard_munin_conf_stat.stat.exists + +- name: Lockdown | Remove legacy Munin UFW rule (src=lxd_gateway_ip) + community.general.ufw: + rule: allow + port: '80' + src: '{{ lxd_gateway_ip }}' + proto: tcp + delete: true diff --git a/deploy/roles/wireguard/tasks/lockdown_postgres.yml b/deploy/roles/wireguard/tasks/lockdown_postgres.yml index 7a30894..29dcd04 100644 --- a/deploy/roles/wireguard/tasks/lockdown_postgres.yml +++ b/deploy/roles/wireguard/tasks/lockdown_postgres.yml @@ -1,24 +1,54 @@ --- -- name: Find pg_hba.conf location +# Per-peer pg_hba.conf rules managed via a single blockinfile so that peer +# removal is idempotent — lineinfile cannot remove a previously-added line +# when the entry disappears from inventory. + +- name: Lockdown | Find pg_hba.conf ansible.builtin.shell: | + set -o pipefail find /etc/postgresql -name pg_hba.conf -type f | head -1 - register: pg_hba_path + args: + executable: /bin/bash + register: wireguard_pg_hba_path changed_when: false -- name: Allow VPN admin access — add lxd_gateway_ip to pg_hba.conf (all databases, all users) - ansible.builtin.lineinfile: - path: '{{ pg_hba_path.stdout | trim }}' - regexp: "^(host|hostssl)\\s+all\\s+all\\s+{{ (lxd_gateway_ip + '/32') | replace('.', '\\.') | replace('/', '\\/') }}" - line: 'hostssl all all {{ lxd_gateway_ip }}/32 scram-sha-256' +- name: Lockdown | Build per-peer pg_hba lines + ansible.builtin.set_fact: + _wireguard_pg_hba_lines: >- + {{ _wireguard_pg_hba_lines | default([]) + [ + 'hostssl ' ~ item.1.database + ~ ' ' ~ item.1.user + ~ ' ' ~ (item.0.peer_ip | default(item.0.allowed_ips.split(',')[0] | trim)) + ~ ' scram-sha-256' + ] }} + loop: "{{ wireguard_peers | subelements('pg_access', skip_missing=True) }}" + loop_control: + label: "{{ item.0.name }} -> {{ item.1.database }}/{{ item.1.user }}" + +- name: Lockdown | Manage per-peer pg_hba block + ansible.builtin.blockinfile: + path: "{{ wireguard_pg_hba_path.stdout | trim }}" + marker: "# {mark} ANSIBLE MANAGED — wireguard per-peer pg_access" + block: "{{ _wireguard_pg_hba_lines | default([]) | join('\n') }}" + state: "{{ 'present' if (_wireguard_pg_hba_lines | default([]) | length > 0) else 'absent' }}" insertbefore: EOF + create: false + notify: Reload PostgreSQL + when: wireguard_pg_hba_path.stdout | trim | length > 0 + +- name: Lockdown | Remove legacy blanket pg_hba VPN admin grant + ansible.builtin.lineinfile: + path: "{{ wireguard_pg_hba_path.stdout | trim }}" + regexp: '^hostssl\s+all\s+all\s+{{ lxd_gateway_ip | replace(".", "\\.") }}/32\s' + state: absent notify: Reload PostgreSQL - when: pg_hba_path.stdout | trim | length > 0 + when: wireguard_pg_hba_path.stdout | trim | length > 0 -- name: Allow PostgreSQL (5432/tcp) from LXD gateway — VPN-only access +- name: Lockdown | Allow PostgreSQL (5432/tcp) from VPN subnet community.general.ufw: rule: allow port: '5432' - src: '{{ lxd_gateway_ip }}' + src: '{{ wireguard_network }}' proto: tcp state: enabled - comment: 'PostgreSQL — VPN access only (via LXD gateway NAT)' + comment: 'PostgreSQL — VPN access only' diff --git a/deploy/roles/wireguard/tasks/lockdown_proxy.yml b/deploy/roles/wireguard/tasks/lockdown_proxy.yml index d1bfb92..9a5b7b6 100644 --- a/deploy/roles/wireguard/tasks/lockdown_proxy.yml +++ b/deploy/roles/wireguard/tasks/lockdown_proxy.yml @@ -5,7 +5,7 @@ patterns: - 'munin*.conf' - 'grafana*.conf' - register: nginx_monitoring_configs + register: wireguard_nginx_monitoring_configs when: proxy | default('nginx') == 'nginx' - name: Empty monitoring configs on nginx proxy (keep files so include directives still resolve) @@ -13,13 +13,13 @@ content: "# Monitoring disabled by WireGuard lockdown\n" dest: '{{ item.path }}' mode: '0644' - loop: '{{ nginx_monitoring_configs.files | default([]) }}' + loop: '{{ wireguard_nginx_monitoring_configs.files | default([]) }}' loop_control: label: '{{ item.path | basename }}' notify: Reload Nginx when: - proxy | default('nginx') == 'nginx' - - nginx_monitoring_configs.files | default([]) | length > 0 + - wireguard_nginx_monitoring_configs.files | default([]) | length > 0 - name: Re-render nginx instance configs without Glowroot proxy blocks ansible.builtin.template: @@ -40,20 +40,20 @@ patterns: - 'munin*.conf' - 'grafana*.conf' - register: apache_monitoring_configs + register: wireguard_apache_monitoring_configs when: proxy | default('nginx') == 'apache2' - name: Remove monitoring configs from apache2 proxy ansible.builtin.file: path: '{{ item.path }}' state: absent - loop: '{{ apache_monitoring_configs.files | default([]) }}' + loop: '{{ wireguard_apache_monitoring_configs.files | default([]) }}' loop_control: label: '{{ item.path | basename }}' notify: Reload Apache2 when: - proxy | default('nginx') == 'apache2' - - apache_monitoring_configs.files | default([]) | length > 0 + - wireguard_apache_monitoring_configs.files | default([]) | length > 0 - name: Re-render apache2 instance configs without Glowroot proxy blocks ansible.builtin.template: diff --git a/deploy/roles/wireguard/tasks/lxd_container.yml b/deploy/roles/wireguard/tasks/lxd_container.yml new file mode 100644 index 0000000..97b7707 --- /dev/null +++ b/deploy/roles/wireguard/tasks/lxd_container.yml @@ -0,0 +1,55 @@ +--- +# user.type="wireguard" is required so custom_lxd dynamic inventory can +# discover the container. + +- name: WireGuard | Create wireguard hub container + vars: + ansible_connection: local + community.general.lxd_container: + config: + boot.autostart.priority: "5" + user.type: "wireguard" + name: "{{ wireguard_hub_inventory_hostname }}" + state: started + profiles: [default] + ignore_volatile_options: false + wait_for_container: true + wait_for_ipv4_addresses: true + timeout: 60 + source: + type: image + mode: pull + server: "{{ lxd_source_server | default('https://cloud-images.ubuntu.com/releases') }}" + protocol: "{{ lxd_source_protocol | default('simplestreams') }}" + alias: "{{ guest_os }}/{{ guest_os_arch | default('amd64') }}" + devices: + eth0: + nictype: bridged + parent: "{{ lxd_bridge_interface | default('lxdbr1') }}" + type: nic + ipv4.address: "{{ wireguard_hub_lxd_ip }}" + register: wireguard_create_status + +# Restart-on-create (not a handler) so the static IP takes effect within +# the same play. +- name: WireGuard | Ensure hub container has its static IP # noqa: no-handler + vars: + ansible_connection: local + community.general.lxd_container: + name: "{{ wireguard_hub_inventory_hostname }}" + state: restarted + wait_for_ipv4_addresses: true + when: wireguard_create_status.changed + register: wireguard_restart_status + +- name: WireGuard | Wait for systemd inside hub container + ansible.builtin.command: + cmd: >- + lxc exec {{ wireguard_hub_inventory_hostname }} -- + test -d /run/systemd/system + changed_when: false + retries: 10 + delay: 2 + register: wireguard_systemd_check + until: wireguard_systemd_check.rc == 0 + when: wireguard_create_status.changed or (wireguard_restart_status.changed | default(false)) diff --git a/deploy/roles/wireguard/tasks/main.yml b/deploy/roles/wireguard/tasks/main.yml index 40af5d0..059bb64 100644 --- a/deploy/roles/wireguard/tasks/main.yml +++ b/deploy/roles/wireguard/tasks/main.yml @@ -1,49 +1,76 @@ --- -- name: WireGuard VPN | Validate peer configuration +# Stage dispatcher. The dhis2.yml playbook calls this role three times with +# different `wireguard_stage` values: +# 1. host_portforward — on 127.0.0.1: provision the wireguard LXD container +# and forward UDP {{ wireguard_port }} from the host's public IP. +# 2. hub — inside the wireguard container: keys, config, peers. +# 3. peer — inside each app container: pull config from hub, +# start wg-quick, apply per-host lockdown rules. + +- name: WireGuard | Validate configuration ansible.builtin.include_tasks: validate.yml when: - wireguard_enabled | bool - - wireguard_peers | length > 0 + - wireguard_stage == 'host_portforward' tags: - always -- name: WireGuard VPN | Server setup - ansible.builtin.include_tasks: server.yml +- name: WireGuard | Provision hub LXD container + ansible.builtin.include_tasks: lxd_container.yml + when: + - wireguard_enabled | bool + - wireguard_stage == 'host_portforward' + +- name: WireGuard | LXD host port-forward + ansible.builtin.include_tasks: host_portforward.yml + when: + - wireguard_enabled | bool + - wireguard_stage == 'host_portforward' + +- name: WireGuard | Hub container setup + ansible.builtin.include_tasks: hub.yml when: - wireguard_enabled | bool - - inventory_hostname == '127.0.0.1' + - wireguard_stage == 'hub' -- name: WireGuard VPN | Firewall rules - ansible.builtin.include_tasks: firewall.yml +# Hosts without wireguard_ip (backup_servers, integration) are not enrolled +# as peers; the hub never renders a config for them. +- name: WireGuard | Peer setup + ansible.builtin.include_tasks: peer.yml when: - wireguard_enabled | bool - - inventory_hostname == '127.0.0.1' + - wireguard_stage == 'peer' + - hostvars[inventory_hostname].wireguard_ip is defined + - hostvars[inventory_hostname].wireguard_ip | length > 0 -- name: WireGuard VPN | Lock down proxy +- name: WireGuard | Lock down proxy ansible.builtin.include_tasks: lockdown_proxy.yml when: - wireguard_enabled | bool - wireguard_lockdown_monitoring | bool + - wireguard_stage == 'peer' - inventory_hostname in groups.get('web', []) -- name: WireGuard VPN | Lock down monitoring +- name: WireGuard | Lock down monitoring ansible.builtin.include_tasks: lockdown_monitor.yml when: - wireguard_enabled | bool - wireguard_lockdown_monitoring | bool + - wireguard_stage == 'peer' - inventory_hostname in groups.get('monitoring', []) -- name: WireGuard VPN | Lock down PostgreSQL +- name: WireGuard | Lock down PostgreSQL ansible.builtin.include_tasks: lockdown_postgres.yml when: - wireguard_enabled | bool - wireguard_lockdown_monitoring | bool + - wireguard_stage == 'peer' - inventory_hostname in groups.get('databases', []) -# Glowroot (4000, app_monitoring=glowroot only) and munin-node (4949, all instances). -- name: WireGuard VPN | Lock down instances +- name: WireGuard | Lock down instances ansible.builtin.include_tasks: lockdown_instances.yml when: - wireguard_enabled | bool - wireguard_lockdown_monitoring | bool + - wireguard_stage == 'peer' - inventory_hostname in groups.get('instances', []) diff --git a/deploy/roles/wireguard/tasks/peer.yml b/deploy/roles/wireguard/tasks/peer.yml new file mode 100644 index 0000000..9b86f93 --- /dev/null +++ b/deploy/roles/wireguard/tasks/peer.yml @@ -0,0 +1,44 @@ +--- +# Runs INSIDE each app container (proxy/postgres/dhis/monitor). +# Pulls the pre-rendered wg0 config from the hub and starts the WG service. + +- name: Peer | Install WireGuard packages + ansible.builtin.apt: + name: + - wireguard + - wireguard-tools + state: present + update_cache: true + cache_valid_time: 3600 + +- name: Peer | Ensure /etc/wireguard exists with secure permissions + ansible.builtin.file: + path: /etc/wireguard + state: directory + owner: root + group: root + mode: '0700' + +- name: Peer | Slurp pre-rendered config from hub + ansible.builtin.slurp: + src: "{{ wireguard_client_config_dir }}/{{ inventory_hostname }}.conf" + delegate_to: "{{ wireguard_hub_inventory_hostname }}" + register: _wireguard_peer_conf + no_log: true + +- name: Peer | Write wg0.conf + ansible.builtin.copy: + content: "{{ _wireguard_peer_conf.content | b64decode }}" + dest: "/etc/wireguard/{{ wireguard_interface }}.conf" + owner: root + group: root + mode: '0600' + no_log: true + notify: Restart WireGuard + +- name: Peer | Enable and start WireGuard service + ansible.builtin.systemd: + name: "wg-quick@{{ wireguard_interface }}" + state: started + enabled: true + daemon_reload: true diff --git a/deploy/roles/wireguard/tasks/server.yml b/deploy/roles/wireguard/tasks/server.yml deleted file mode 100644 index a8ebcdd..0000000 --- a/deploy/roles/wireguard/tasks/server.yml +++ /dev/null @@ -1,143 +0,0 @@ ---- -- name: Gather network facts for client config endpoint fallback - ansible.builtin.setup: - gather_subset: - - '!all' - - 'network' - -- name: Install WireGuard packages - ansible.builtin.apt: - name: - - wireguard - - wireguard-tools - state: present - update_cache: true - cache_valid_time: 3600 - -- name: Ensure /etc/wireguard directory exists with secure permissions - ansible.builtin.file: - path: /etc/wireguard - state: directory - owner: root - group: root - mode: '0700' - -- name: Ensure clients config directory exists - ansible.builtin.file: - path: '{{ wireguard_client_config_dir }}' - state: directory - owner: root - group: root - mode: '0700' - -- name: Check if server private key exists - ansible.builtin.stat: - path: /etc/wireguard/server_private.key - register: wg_private_key_stat - -- name: Generate WireGuard server keypair - ansible.builtin.shell: | - set -o pipefail - umask 077 - wg genkey | tee /etc/wireguard/server_private.key | wg pubkey > /etc/wireguard/server_public.key - args: - executable: /bin/bash - when: not wg_private_key_stat.stat.exists - changed_when: true - -- name: Set restrictive permissions on key files - ansible.builtin.file: - path: '/etc/wireguard/{{ item }}' - owner: root - group: root - mode: '0600' - loop: - - server_private.key - - server_public.key - -- name: Read server private key - ansible.builtin.slurp: - src: /etc/wireguard/server_private.key - register: wg_server_private_key - no_log: true - -- name: Read server public key - ansible.builtin.slurp: - src: /etc/wireguard/server_public.key - register: wg_server_public_key - -- name: Display server public key (share with VPN clients) - ansible.builtin.debug: - msg: >- - WireGuard Server Public Key: - {{ wg_server_public_key.content | b64decode | trim }} - -# Required for routing VPN traffic into the LXD bridge. -- name: Enable net.ipv4.ip_forward persistently - ansible.posix.sysctl: - name: net.ipv4.ip_forward - value: '1' - state: present - reload: true - sysctl_set: true - -- name: Generate client keys (auto-generate mode) - ansible.builtin.include_tasks: generate_client_keys.yml - when: - - wireguard_auto_generate_keys | bool - - wireguard_peers | length > 0 - -- name: Use provided peers when not auto-generating or no peers defined - ansible.builtin.set_fact: - wireguard_peers_resolved: '{{ wireguard_peers }}' - when: not (wireguard_auto_generate_keys | bool) or wireguard_peers | length == 0 - -- name: Deploy WireGuard server configuration - ansible.builtin.template: - src: wg0.conf.j2 - dest: '/etc/wireguard/{{ wireguard_interface }}.conf' - owner: root - group: root - mode: '0600' - notify: "{{ 'Sync WireGuard peers' if wg_private_key_stat.stat.exists else 'Restart WireGuard' }}" - -- name: Enable and start WireGuard service - ansible.builtin.systemd: - name: 'wg-quick@{{ wireguard_interface }}' - state: started - enabled: true - daemon_reload: true - -- name: Warn when no VPN peers are configured - ansible.builtin.debug: - msg: >- - WARNING: wireguard_peers is empty. WireGuard will start but no client - can connect. Add at least one peer to your inventory before use. - when: wireguard_peers | length == 0 - -- name: Generate client configuration files - ansible.builtin.template: - src: client.conf.j2 - dest: '{{ wireguard_client_config_dir }}/{{ item.name }}.conf' - owner: root - group: root - mode: '0600' - no_log: true - loop: '{{ wireguard_peers_resolved }}' - loop_control: - label: '{{ item.name }}' - when: item._private_key is defined and item._private_key | length > 0 - -- name: Display client retrieval instructions - ansible.builtin.debug: - msg: | - {{ wireguard_peers_resolved | length }} ready-to-use client config(s) generated. - - Retrieve configs: - sudo cat {{ wireguard_client_config_dir }}/.conf - - Copy to local machine: - scp @{{ fqdn | default(ansible_default_ipv4.address, true) }}:{{ wireguard_client_config_dir }}/.conf . - when: - - wireguard_peers_resolved is defined - - wireguard_peers_resolved | length > 0 diff --git a/deploy/roles/wireguard/tasks/validate.yml b/deploy/roles/wireguard/tasks/validate.yml index 727eec7..baeae97 100644 --- a/deploy/roles/wireguard/tasks/validate.yml +++ b/deploy/roles/wireguard/tasks/validate.yml @@ -1,6 +1,6 @@ --- -# Pre-flight validation for wireguard_peers. -# Runs early to fail fast before template rendering produces cryptic WireGuard errors. +# Pre-flight validation. Runs early to fail fast before template rendering +# produces cryptic WireGuard errors. - name: WireGuard | Assert each peer has required fields ansible.builtin.assert: @@ -55,3 +55,121 @@ Values: {{ wireguard_peers | map(attribute='allowed_ips') | list }} quiet: true when: wireguard_peers | length > 1 + +- name: WireGuard | Assert pg_access entries are well-formed + ansible.builtin.assert: + that: + - item.1.database is defined + - item.1.database is string + - item.1.database | length > 0 + - item.1.user is defined + - item.1.user is string + - item.1.user | length > 0 + fail_msg: >- + wireguard_peers[{{ item.0.name }}].pg_access entry must have non-empty + 'database' and 'user' string fields. + quiet: true + loop: "{{ wireguard_peers | subelements('pg_access', skip_missing=True) }}" + loop_control: + label: "{{ item.0.name }} -> {{ item.1.database | default('?') }}/{{ item.1.user | default('?') }}" + +- name: WireGuard | Assert pg_access database/user names are PostgreSQL-safe identifiers + ansible.builtin.assert: + that: + - item.1.database is match('^[a-zA-Z0-9_]+$') + - item.1.user is match('^[a-zA-Z0-9_]+$') + fail_msg: >- + wireguard_peers[{{ item.0.name }}].pg_access entry + database='{{ item.1.database }}' user='{{ item.1.user }}' contains + invalid characters. Allowed: letters, digits, underscore. Required + because these values are interpolated into pg_hba.conf rule regex + matching — metacharacters would corrupt idempotency. + quiet: true + loop: "{{ wireguard_peers | subelements('pg_access', skip_missing=True) }}" + loop_control: + label: "{{ item.0.name }} -> {{ item.1.database }}/{{ item.1.user }}" + +- name: WireGuard | Assert peer_ip (or first allowed_ips CIDR) is /32 for peers with pg_access + ansible.builtin.assert: + that: + - >- + ((item.peer_ip | default(item.allowed_ips.split(',')[0] | trim)) + | regex_search('/32$')) is not none + fail_msg: >- + wireguard_peers[{{ item.name }}] uses pg_access but its peer_ip + (or first allowed_ips CIDR) does not end in /32. Per-peer pg_hba/UFW + rules treat the CIDR as a single host — non-/32 entries would silently + open access wider than intended. Set peer_ip explicitly or ensure the + first allowed_ips CIDR is /32. + Got: peer_ip={{ item.peer_ip | default('(unset)') }} + allowed_ips={{ item.allowed_ips }} + quiet: true + loop: "{{ wireguard_peers }}" + loop_control: + label: "{{ item.name }}" + when: item.pg_access is defined and item.pg_access | length > 0 + +- name: WireGuard | Assert hub container is in inventory + ansible.builtin.assert: + that: + - wireguard_hub_inventory_hostname in groups['all'] + fail_msg: >- + wireguard_hub_inventory_hostname='{{ wireguard_hub_inventory_hostname }}' + is not in inventory. Add a [wireguard] group with the hub host + (e.g. 'wireguard ansible_host=172.19.2.200 wireguard_ip=10.0.0.1'). + quiet: true + run_once: true + +- name: WireGuard | Assert every app host (web/databases/instances/monitoring) has wireguard_ip + ansible.builtin.assert: + that: + - hostvars[item].wireguard_ip is defined + - hostvars[item].wireguard_ip | length > 0 + fail_msg: >- + Host '{{ item }}' is in an app group (web/databases/instances/monitoring) + but has no wireguard_ip set. Add 'wireguard_ip=<10.0.0.x>' to the + inventory entry, or set wireguard_enabled=false to skip WireGuard. + quiet: true + loop: >- + {{ (groups.get('web', []) + groups.get('databases', []) + + groups.get('instances', []) + groups.get('monitoring', [])) + | unique | list }} + loop_control: + label: "{{ item }}" + run_once: true + +# Two regex passes (split CIDR list, then strip mask) for portability across +# Ansible/Jinja versions. For human peers with comma-separated allowed_ips, +# this picks the peer's own VPN IP, not a downstream subnet. +- name: WireGuard | Build canonical peer IP list for uniqueness check + ansible.builtin.set_fact: + _wireguard_human_peer_ips: >- + {{ wireguard_peers + | map(attribute='allowed_ips') + | map('regex_replace', '^\\s*([^,\\s]+).*$', '\\1') + | map('regex_replace', '/[0-9]+$', '') + | list }} + run_once: true + +- name: WireGuard | Collect all wireguard IPs (containers + human peers) for uniqueness check + ansible.builtin.set_fact: + _wireguard_all_ips: >- + {{ (groups['all'] + | difference(['127.0.0.1']) + | map('extract', hostvars, 'wireguard_ip') + | select('defined') + | list) + + _wireguard_human_peer_ips }} + run_once: true + +- name: WireGuard | Assert no duplicate wireguard IPs across containers and human peers + ansible.builtin.assert: + that: + - _wireguard_all_ips | unique | length == _wireguard_all_ips | length + fail_msg: >- + Duplicate WireGuard IP detected across inventory wireguard_ip and + wireguard_peers.allowed_ips. Each address in {{ wireguard_network }} + must be assigned exactly once. + Values: {{ _wireguard_all_ips }} + quiet: true + run_once: true diff --git a/deploy/roles/wireguard/templates/client.conf.j2 b/deploy/roles/wireguard/templates/client.conf.j2 index c76abec..8a7fca3 100644 --- a/deploy/roles/wireguard/templates/client.conf.j2 +++ b/deploy/roles/wireguard/templates/client.conf.j2 @@ -1,16 +1,22 @@ -# WireGuard Client: {{ item.name }} +# WireGuard peer config: {{ item.name }} ({{ item.endpoint_kind }}) # Generated by Ansible — do not commit to version control [Interface] -Address = {{ item.allowed_ips }} +Address = {{ item.wireguard_ip }}/32 PrivateKey = {{ item._private_key }} +{% if item.endpoint_kind == 'external' %} # DNS = 1.1.1.1, 8.8.8.8 +{% endif %} [Peer] -PublicKey = {{ wg_server_public_key.content | b64decode | trim }} +PublicKey = {{ wireguard_server_public_key_data.content | b64decode | trim }} {% if item._preshared_key is defined and item._preshared_key %} PresharedKey = {{ item._preshared_key }} {% endif %} -Endpoint = {{ fqdn | default(ansible_default_ipv4.address, true) }}:{{ wireguard_port }} -AllowedIPs = {{ wireguard_network }}, {{ lxd_network }} +{% if item.endpoint_kind == 'internal' %} +Endpoint = {{ wireguard_hub_lxd_ip }}:{{ wireguard_port }} +{% else %} +Endpoint = {{ wireguard_endpoint_public_resolved }}:{{ wireguard_port }} +{% endif %} +AllowedIPs = {{ wireguard_network }} PersistentKeepalive = 25 diff --git a/deploy/roles/wireguard/templates/wg0.conf.j2 b/deploy/roles/wireguard/templates/wg0.conf.j2 index 5037120..f80ab94 100644 --- a/deploy/roles/wireguard/templates/wg0.conf.j2 +++ b/deploy/roles/wireguard/templates/wg0.conf.j2 @@ -1,32 +1,17 @@ -# Managed by Ansible — do not edit manually -# Architecture: VPN clients → wg0 → kernel forwarding → lxdbr1 → LXD containers +{{ ansible_managed | comment }} +# WireGuard hub config — runs inside the {{ wireguard_hub_inventory_hostname }} container. +# Spoke ↔ spoke routing happens via kernel forwarding on this hub +# (net.ipv4.ip_forward=1 + iptables FORWARD wg0↔wg0 ACCEPT). [Interface] Address = {{ wireguard_server_ip }}/24 ListenPort = {{ wireguard_port }} -PrivateKey = {{ wg_server_private_key.content | b64decode | trim }} -# Prevent wg-quick from overwriting this Ansible-managed file with runtime state. -# Without this, 'wg-quick down' could persist dynamically-added peers back to disk. +PrivateKey = {{ wireguard_server_private_key_data.content | b64decode | trim }} SaveConfig = false -# FORWARD rules: allow packets between wg0 and the LXD bridge. -# MASQUERADE: translate VPN source IPs so containers can reply through the bridge -# without needing a static route to 10.8.0.0/24. %i expands to the interface name. -PostUp = iptables -A FORWARD -i %i -o {{ lxd_bridge_interface }} -s {{ wireguard_network }} -d {{ lxd_network }} -j ACCEPT -PostUp = iptables -A FORWARD -i {{ lxd_bridge_interface }} -o %i -s {{ lxd_network }} -d {{ wireguard_network }} -j ACCEPT -PostUp = iptables -t nat -A POSTROUTING -s {{ wireguard_network }} -o {{ lxd_bridge_interface }} -j MASQUERADE -PostUp = ip6tables -A FORWARD -i %i -j DROP -PostUp = ip6tables -A FORWARD -o %i -j DROP - -PostDown = iptables -D FORWARD -i %i -o {{ lxd_bridge_interface }} -s {{ wireguard_network }} -d {{ lxd_network }} -j ACCEPT -PostDown = iptables -D FORWARD -i {{ lxd_bridge_interface }} -o %i -s {{ lxd_network }} -d {{ wireguard_network }} -j ACCEPT -PostDown = iptables -t nat -D POSTROUTING -s {{ wireguard_network }} -o {{ lxd_bridge_interface }} -j MASQUERADE -PostDown = ip6tables -D FORWARD -i %i -j DROP -PostDown = ip6tables -D FORWARD -o %i -j DROP - {% for peer in wireguard_peers_resolved %} [Peer] -# {{ peer.name }} +# {{ peer.name }} ({{ peer.endpoint_kind }}) PublicKey = {{ peer.public_key }} AllowedIPs = {{ peer.allowed_ips }} {% if peer._preshared_key is defined and peer._preshared_key %} diff --git a/docs/WireGuard-VPN.md b/docs/WireGuard-VPN.md index 2f0ee71..d22d776 100644 --- a/docs/WireGuard-VPN.md +++ b/docs/WireGuard-VPN.md @@ -1,46 +1,72 @@ # WireGuard VPN for DHIS2 Server Tools -WireGuard provides a secure VPN tunnel for administering DHIS2 infrastructure. When enabled, monitoring dashboards (Grafana, Prometheus, Munin), application performance monitoring (Glowroot), and direct PostgreSQL access are restricted to VPN-connected clients only. Public DHIS2 web access remains unaffected. +WireGuard provides a secure VPN tunnel for administering DHIS2 infrastructure. The hub runs in its own LXD container; every app container (proxy, postgres, dhis, monitor) joins as a WG peer with its own `wg0` interface. Home/admin peers (sysadmin laptops) connect to the hub via the LXD host's public IP over UDP `51820`. Public DHIS2 web access is unaffected. ## Architecture ``` - Internet - │ - ┌──────┴──────┐ - │ Host (UFW) │ - │ │ - ┌──────┤ wg0 │◄──── VPN Clients (10.8.0.0/24) - │ │ 10.8.0.1 │ UDP port 51820 - │ └──────┬──────┘ - │ │ - │ lxdbr1 (172.19.2.1/24) - │ │ - ┌────────┼─────────────┼─────────────┐ - │ │ │ │ - ┌───┴───┐ ┌─┴──────┐ ┌────┴────┐ ┌──────┴───┐ - │ proxy │ │postgres│ │ dhis │ │ monitor │ - │ .2 │ │ .20 │ │ .11 │ │ .30 │ - └───────┘ └────────┘ └────────┘ └──────────┘ + Internet + │ + ┌──────┴──────┐ + │ LXD Host │ 104.105.9.136 + │ lxc fwd │ UDP 51820 → 172.19.2.200:51820 + └──────┬──────┘ + │ + ┌────────────┼────────────┐ + │ lxdbr1 (172.19.2.0/24) + │ │ + ┌──────┴───────┐ │ + │ wireguard │ │ LXD bridge (in-band, encrypted by WG) + │ 172.19.2.200 │◄───┼─── wg over UDP between hub and peers + │ wg 10.0.0.1 │ │ + └──────────────┘ │ + │ + ┌──────┐ ┌──────────┐ ┌──────┐ ┌──────────┐ + │proxy │ │ postgres │ │ dhis │ │ monitor │ + │.2/.2 │ │ .20/.3 │ │.11/.4│ │ .30/.5 │ + └──────┘ └──────────┘ └──────┘ └──────────┘ + ▲ + │ wg + ┌────────────┐ │ + │ Home/admin │ wg 10.0.0.6 ──────────┘ (over public internet, + └────────────┘ host fwd's UDP 51820) ``` -**Traffic flow:** +(Numbers under each box: `/`.) -1. VPN client connects to the host on UDP port 51820. -2. The host kernel forwards packets from `wg0` to `lxdbr1` via iptables FORWARD rules. -3. MASQUERADE translates VPN source IPs (10.8.0.x) to the LXD gateway IP (172.19.2.1) so containers can route replies without needing a static route back to the VPN subnet. -4. Container-level UFW rules allow traffic only from the LXD gateway IP, ensuring that only VPN-routed traffic reaches protected services. +**Topology**: hub-and-spoke. Each spoke (app container, home machine) has a single `[Peer]` pointing at the hub with `AllowedIPs = 10.0.0.0/24` and `PersistentKeepalive = 25`. Spoke ↔ spoke traffic relays through the hub (which has `net.ipv4.ip_forward=1` and `iptables -A FORWARD -i wg0 -o wg0 -j ACCEPT`). + +**Endpoint resolution**: +- App container peers: `Endpoint = 172.19.2.200:51820` — resolved internally over `lxdbr1`. +- Home peers: `Endpoint = :51820` — resolved over the internet, NAT'd by the LXD host through `lxc network forward`. + +Two separate vars control this: + +| Var | Used for | Default | +|---|---|---| +| `wireguard_endpoint_listen` | `lxc network forward` listen address on the host | auto-detect (`ansible_default_ipv4.address`) | +| `wireguard_endpoint_public` | `Endpoint =` line written into home-peer `.conf` files | falls back to `wireguard_endpoint_listen` | + +On a host with a single primary public IP, leaving both empty works. On cloud VMs with 1:1 NAT (AWS EIP, GCP external IP, Azure public IP), the host's primary interface holds a *private* IP — auto-detect picks the private IP. The forward must still bind on that private IP (it is the only IP the host owns), but home peers must dial the public IP. In that case set: + +```ini +# in inventory/hosts [all:vars] +wireguard_endpoint_public=203.0.113.42 # public IP or DNS name +# wireguard_endpoint_listen left empty — auto-detect picks the private primary +``` + +**App-to-app traffic** (e.g. dhis → postgres) continues to use the LXD bridge (`172.19.2.x`); only admin/external traffic is routed through WG. UFW lockdown rules continue to allow `src=10.0.0.0/24`; packets arrive on each container's `wg0` with the peer's WG IP as source. ## Prerequisites -- Ubuntu 22.04+ (kernel >= 5.6 includes the WireGuard kernel module) +- Ubuntu 22.04+ (kernel ≥ 5.6 includes the WireGuard kernel module) - UFW firewall enabled on the host - A working dhis2-server-tools LXD deployment (or ready to deploy) - WireGuard client installed on your admin workstation ## Quick Start -### 1. Configure the Inventory +### 1. Configure the inventory Edit `deploy/inventory/hosts` and set: @@ -49,22 +75,41 @@ Edit `deploy/inventory/hosts` and set: wireguard_enabled=true ``` -### 2. Define VPN Peers +The default `hosts.template` already lists the hub and per-host `wireguard_ip`: + +```ini +[web] +proxy ansible_host=172.19.2.2 wireguard_ip=10.0.0.2 + +[databases] +postgres ansible_host=172.19.2.20 wireguard_ip=10.0.0.3 + +[instances] +dhis ansible_host=172.19.2.11 ... wireguard_ip=10.0.0.4 + +[monitoring] +monitor ansible_host=172.19.2.30 wireguard_ip=10.0.0.5 + +[wireguard] +wireguard ansible_host=172.19.2.200 wireguard_ip=10.0.0.1 +``` -Edit `deploy/inventory/group_vars/all/vars.yml`: +### 2. Define human peers + +Edit `deploy/inventory/group_vars/all/vars.yml`. Assign IPs from `10.0.0.6` upward (`.2`–`.5` are reserved for app containers in the default inventory): ```yaml wireguard_peers: - name: sysadmin - allowed_ips: "10.8.0.2/32" + allowed_ips: "10.0.0.6/32" + pg_access: + - { database: dhis, user: dhis } - name: admin-bob - allowed_ips: "10.8.0.3/32" + allowed_ips: "10.0.0.7/32" ``` -Each peer needs only a **name** and **IP address**. Client keys are generated automatically on the server. - -Each peer must have a unique IP address within the `10.8.0.0/24` subnet. The server uses `10.8.0.1`; assign clients starting from `10.8.0.2`. +Each peer needs only a **name** and **IP address**. Keypairs are generated hub-side automatically. ### 3. Deploy @@ -79,22 +124,23 @@ sudo ansible-playbook dhis2.yml --tags wireguard ``` The playbook will: -- Install WireGuard packages on the host -- Generate server keypair (preserved across runs) -- Generate client keypairs server-side (preserved across runs) -- Deploy the `wg0` interface configuration -- Configure UFW and iptables forwarding rules -- Lock down monitoring and database access to VPN-only -- Generate complete, ready-to-import client configs in `/etc/wireguard/clients/` +- Provision the `wireguard` LXD container at `172.19.2.200`. +- Add an `lxc network forward` rule so UDP `51820` from the host's public IP lands inside the wireguard container. +- Install WireGuard packages inside the hub and inside every app container. +- Generate hub + peer keypairs (preserved across runs). +- Render `wg0.conf` for the hub and per-peer `.conf` files for every container and every human peer. +- Pull each app container's config from the hub via Ansible `slurp` and start `wg-quick@wg0`. +- Lock down monitoring and database access to VPN-only. -### 4. Retrieve and Import Client Config +### 4. Retrieve and import a human peer config ```bash -# View the config -sudo cat /etc/wireguard/clients/sysadmin.conf +# View the config (it's rendered inside the hub container) +sudo lxc exec wireguard -- cat /etc/wireguard/clients/sysadmin.conf -# Copy to local machine -scp your-user@your-server:/etc/wireguard/clients/sysadmin.conf . +# Or copy it out to the host and scp +sudo lxc file pull wireguard/etc/wireguard/clients/sysadmin.conf . +scp sysadmin.conf my-laptop:~/ ``` The config is complete — no editing needed. Import directly into your WireGuard client. @@ -108,223 +154,219 @@ sudo wg-quick up /path/to/sysadmin.conf # macOS / Windows # Import the .conf file into the WireGuard app -# Mobile (generate QR code on the server) -sudo qrencode -t ansiutf8 < /etc/wireguard/clients/sysadmin.conf +# Mobile (generate QR code on the hub) +sudo lxc exec wireguard -- qrencode -t ansiutf8 < /etc/wireguard/clients/sysadmin.conf ``` -### 5. Verify Connectivity +### 5. Verify connectivity ```bash -# Check VPN interface is up -sudo wg show - -# Ping the VPN server -ping 10.8.0.1 - -# Ping an LXD container (e.g., monitoring) -ping 172.19.2.30 - -# Access Grafana directly (if monitoring is Grafana/Prometheus) -curl http://172.19.2.30:3000 - -# Access Munin directly (if monitoring is Munin) -curl http://172.19.2.30/munin/ - -# Connect to PostgreSQL directly -psql -h 172.19.2.20 -U dhis -d dhis2 +# On the home machine (connected over WG): +ping 10.0.0.1 # hub +ping 10.0.0.5 # monitor (via mesh) +curl http://10.0.0.5:3000 # Grafana (locked-down, VPN-only) +psql -h 10.0.0.3 -U dhis -d dhis2 + +# On the LXD host: +sudo lxc exec wireguard -- wg show +sudo lxc exec proxy -- wg show +sudo lxc network forward show lxdbr1 # confirms UDP 51820 forward ``` -## What Gets Locked Down +## What gets locked down When `wireguard_enabled=true` and `wireguard_lockdown_monitoring=true` (default): | Service | Container | Port | Before VPN | After VPN | |---|---|---|---|---| -| Grafana | monitor | 3000 | Accessible via proxy `/grafana` | VPN-only direct access | -| Prometheus | monitor | 9090 | Accessible via proxy | VPN-only direct access | -| Munin | monitor | 80 | Accessible via proxy `/munin` | VPN-only direct access | -| Glowroot | dhis instances | 4000 | Accessible from LXD network | VPN-only via LXD gateway | +| Grafana | monitor | 3000 | Accessible via proxy `/grafana` | VPN-only (`10.0.0.5:3000`) | +| Prometheus | monitor | 9090 | Accessible via proxy | VPN-only | +| Munin | monitor | 80 | Accessible via proxy `/munin` | VPN-only | +| Glowroot | dhis instances | 4000 | Accessible from LXD network | VPN-only | | munin-node | dhis instances | 4949 | Accessible from monitor container | Monitor container only | -| PostgreSQL | postgres | 5432 | LXD network only | VPN-only direct access | +| PostgreSQL | postgres | 5432 | LXD network only | VPN-only, per-peer rules | -**Not affected:** DHIS2 web application (HTTP/HTTPS on ports 80/443 through the proxy) remains publicly accessible. +**Not affected**: DHIS2 web app on ports 80/443 through the proxy stays public. ### PostgreSQL VPN access -The VPN lockdown adds a `host all all /32 scram-sha-256` entry to `pg_hba.conf`. This grants VPN users access to all databases as any PostgreSQL user — intentionally broad to support ad-hoc admin access (`psql`) over the VPN. A password is still required (`scram-sha-256`). Application-level pg_hba entries (per-instance db/user restrictions) are managed separately by the `create-instance` role. +Database access is granted **per peer** via the optional `pg_access` field. A peer without `pg_access` has **no** PostgreSQL access — the role does not add a blanket grant. + +Each `pg_access` entry is a `{database, user}` pair. The role writes one `hostssl scram-sha-256` line to `pg_hba.conf` per entry. A password is still required. + +```yaml +wireguard_peers: + - name: sysadmin + allowed_ips: "10.0.0.6/32" + pg_access: + - { database: dhis, user: dhis } # least-privilege + + # - name: superuser + # allowed_ips: "10.0.0.7/32" + # pg_access: + # - { database: all, user: all } # superuser-equivalent +``` + +`database` and `user` must match `^[a-zA-Z0-9_]+$`. The PostgreSQL keyword `all` is allowed; arbitrary identifiers with regex metacharacters are rejected at validation time. + +If a peer's `allowed_ips` routes additional networks (comma-separated CIDRs), set `peer_ip` explicitly to the single `/32` used for pg_hba/UFW rules. + +App-level pg_hba entries (added by the `create-instance` role) are unaffected — they continue to work over the LXD bridge. + +The role manages all `pg_access`-derived rules inside a single `blockinfile` block delimited by `# BEGIN/END ANSIBLE MANAGED — wireguard per-peer pg_access`. Removing a peer (or its `pg_access` entry) and re-running the role removes the corresponding `hostssl` line. A blanket `host all all /32` grant added by older versions of this role is removed on upgrade. ### Restoring public monitoring access -Enabling `wireguard_lockdown_monitoring` removes monitoring proxy configs (Grafana/Munin paths) from nginx/apache2. To restore public proxy access: +Set `wireguard_lockdown_monitoring: false` and re-run: -1. Set `wireguard_lockdown_monitoring: false` in your inventory. -2. Re-run the playbook: - ```bash - sudo ansible-playbook dhis2.yml --tags monitoring,proxy-install,wireguard - ``` +```bash +sudo ansible-playbook dhis2.yml --tags monitoring,proxy-install,wireguard +``` The monitoring and proxy roles recreate the deleted config files idempotently. -## Configuration Reference +## Configuration reference All variables are set in `deploy/roles/wireguard/defaults/main.yml` and can be overridden in the inventory. | Variable | Default | Description | |---|---|---| -| `wireguard_enabled` | `false` | Master switch — must be `true` to activate | -| `wireguard_network` | `10.8.0.0/24` | VPN subnet | -| `wireguard_server_ip` | `10.8.0.1` | Server address on the VPN | +| `wireguard_enabled` | `false` | Master switch | +| `wireguard_network` | `10.0.0.0/24` | VPN subnet | +| `wireguard_server_ip` | `10.0.0.1` | Hub address on the VPN | | `wireguard_port` | `51820` | UDP listen port | | `wireguard_interface` | `wg0` | WireGuard interface name | -| `wireguard_auto_generate_keys` | `true` | Generate client keypairs server-side | -| `wireguard_auto_generate_psk` | `false` | Auto-generate pre-shared keys for peers | -| `wireguard_client_config_dir` | `/etc/wireguard/clients` | Directory for client config files | -| `wireguard_client_key_dir` | `/etc/wireguard/clients/keys` | Directory for client key files | +| `wireguard_hub_inventory_hostname` | `wireguard` | Inventory name of the hub container | +| `wireguard_hub_lxd_ip` | `172.19.2.200` | Static LXD IP for the hub container | +| `wireguard_endpoint_listen` | `""` | Host-side listen IP for `lxc network forward`. Must be on the host. Auto-detect via `ansible_default_ipv4.address` when empty | +| `wireguard_endpoint_public` | `""` | Public IP/hostname advertised to home peers as `Endpoint =`. Falls back to `wireguard_endpoint_listen` when empty. **Set explicitly on cloud VMs with 1:1 NAT.** | +| `wireguard_auto_generate_keys` | `true` | Generate peer keypairs hub-side | +| `wireguard_auto_generate_psk` | `false` | Auto-generate pre-shared keys | +| `wireguard_client_config_dir` | `/etc/wireguard/clients` | Directory on the hub for peer configs | +| `wireguard_client_key_dir` | `/etc/wireguard/clients/keys` | Directory on the hub for peer keys | | `wireguard_prune_orphans` | `false` | Remove files for peers no longer in inventory | -| `lxd_bridge_interface` | `lxdbr1` | LXD bridge (must match your LXD network) | -| `lxd_network` | `172.19.2.0/24` | LXD container subnet | -| `lxd_gateway_ip` | `172.19.2.1` | Host-side IP of the LXD bridge | | `wireguard_lockdown_monitoring` | `true` | Restrict monitoring/DB to VPN-only | -| `wireguard_peers` | `[]` | List of VPN client peers | +| `wireguard_peers` | `[]` | List of human/admin peers | -### Peer definition +### Peer definition (human peers only) -Each entry in `wireguard_peers` accepts: +App containers are auto-derived from inventory `wireguard_ip` and must NOT be listed in `wireguard_peers`. | Field | Required | Description | |---|---|---| -| `name` | Yes | Identifier — must be filesystem-safe (letters, digits, dot, underscore, hyphen) | -| `allowed_ips` | Yes | Client's VPN IP (e.g., `10.8.0.2/32`) | -| `public_key` | No* | Client's WireGuard public key. *Required only when `wireguard_auto_generate_keys: false` | -| `preshared_key` | No | Optional preshared key for post-quantum security | +| `name` | Yes | Identifier — filesystem-safe (letters, digits, dot, underscore, hyphen) | +| `allowed_ips` | Yes | Peer's VPN IP (e.g. `10.0.0.6/32`). May be comma-separated to route additional networks | +| `public_key` | No* | Peer's WG public key. *Required only when `wireguard_auto_generate_keys: false` | +| `preshared_key` | No | Optional PSK for post-quantum hedge | +| `peer_ip` | No | Single `/32` CIDR for pg_hba/UFW rules. Defaults to first CIDR in `allowed_ips` | +| `pg_access` | No | List of `{database, user}` — adds per-peer pg_hba rules | ### Key generation modes -**Auto-generate (default):** `wireguard_auto_generate_keys: true` +**Auto-generate (default)**: `wireguard_auto_generate_keys: true` -Only `name` and `allowed_ips` required per peer. The playbook generates client keypairs on the server and produces complete, ready-to-import `.conf` files. If a peer provides `public_key`, auto-generation is skipped for that peer (no client `.conf` emitted since the server never sees the private key). +Only `name` and `allowed_ips` required per human peer. The hub container generates every keypair — including its own and one per app container — and produces complete, ready-to-import `.conf` files. -**Manual keys:** `wireguard_auto_generate_keys: false` +**Manual keys**: `wireguard_auto_generate_keys: false` -Each peer must provide a `public_key`. This is the traditional workflow where admins generate keys on their workstation and paste the public key into inventory. +Each peer must supply a `public_key`. App containers fall back to manual key supply via host_vars (advanced, rare). ### Pre-shared key (PSK) auto-generation -Set `wireguard_auto_generate_psk: true` to generate a PSK for each peer that doesn't supply an explicit `preshared_key`. PSKs add a symmetric post-quantum hedge. +Set `wireguard_auto_generate_psk: true` to generate a PSK for each peer that doesn't supply an explicit `preshared_key`. -> **Warning:** Enabling this on an existing deployment generates fresh PSKs on the next run for every peer that lacks an explicit `preshared_key`. All affected clients must re-import their `.conf`. +> **Warning**: enabling on an existing deployment generates fresh PSKs on the next run for every peer that lacks an explicit `preshared_key`. All affected clients must re-import their `.conf`. -## Split Tunneling +## Split tunneling -The client configuration uses **split tunneling** by default: only traffic destined for the VPN subnet (`10.8.0.0/24`) and the LXD container subnet (`172.19.2.0/24`) routes through the tunnel. All other internet traffic uses the client's normal connection. - -To route all traffic through the VPN (full tunnel), change the client config: +Default is split-tunnel: only `10.0.0.0/24` routes through WG. App-to-app traffic continues via the LXD bridge. To route all client traffic through the VPN, edit a human peer's `.conf` and change: ```ini # Split tunnel (default) -AllowedIPs = 10.8.0.0/24, 172.19.2.0/24 +AllowedIPs = 10.0.0.0/24 # Full tunnel AllowedIPs = 0.0.0.0/0 ``` -## Adding and Removing Peers +## Adding and removing peers ### Adding a new peer -1. Add the peer to `wireguard_peers` in your inventory with a unique name and IP. -2. Re-run the playbook: +Add the peer to `wireguard_peers` and re-run: ```bash sudo ansible-playbook dhis2.yml --tags wireguard ``` -3. Retrieve the config from `/etc/wireguard/clients/.conf`. +Then retrieve the config from `/etc/wireguard/clients/.conf` inside the hub container. -The playbook uses `wg syncconf` to apply peer changes **without dropping existing VPN sessions**. +The hub uses `wg syncconf` to apply peer changes **without dropping existing VPN sessions**. ### Removing a peer -1. Remove the peer entry from `wireguard_peers`. -2. Re-run the playbook: +Remove the peer entry and re-run. Set `wireguard_prune_orphans: true` to also clean up orphaned key/config files. -```bash -sudo ansible-playbook dhis2.yml --tags wireguard -``` - -The `wg syncconf` command removes peers that are no longer in the configuration. To also clean up orphaned key and config files, set `wireguard_prune_orphans: true`. - -### Rotating client keys - -Delete the client's key files on the server and re-run: +### Rotating peer keys ```bash -# Rotate keys only -sudo rm /etc/wireguard/clients/keys/sysadmin.{key,pub} - -# Rotate PSK only -sudo rm /etc/wireguard/clients/keys/sysadmin.psk - -# Rotate both -sudo rm /etc/wireguard/clients/keys/sysadmin.{key,pub,psk} - +# On the LXD host, reach into the hub container: +sudo lxc exec wireguard -- rm /etc/wireguard/clients/keys/sysadmin.{key,pub,psk} sudo ansible-playbook dhis2.yml --tags wireguard ``` -The affected client must re-import their updated `.conf`. +The affected peer must re-import their updated `.conf`. -## File Layout on Server +## File layout on the hub container ``` /etc/wireguard/ -├── wg0.conf # Server config -├── server_private.key # Server private key -├── server_public.key # Server public key +├── wg0.conf +├── server_private.key +├── server_public.key └── clients/ - ├── user.conf # Complete, ready-to-import config - ├── sysadmin.conf + ├── proxy.conf # auto-derived app container + ├── postgres.conf + ├── dhis.conf + ├── monitor.conf + ├── sysadmin.conf # human peer └── keys/ - ├── user.key # Client private key (0600) - ├── user.pub # Client public key (0600) - ├── user.psk # Client PSK (0600, only if auto_generate_psk=true) - ├── sysadmin.key - ├── sysadmin.pub - └── sysadmin.psk + ├── proxy.{key,pub} + ├── sysadmin.{key,pub,psk} + └── ... ``` -## Network Customization - -### Changing the VPN subnet +## Migration from 10.8.0.0/24 (host-bridge architecture) -If `10.8.0.0/24` conflicts with your network, override it in the inventory: +Earlier versions of this role ran WireGuard on the LXD host with a `wg0 ↔ lxdbr1` bridge and used the `10.8.0.0/24` subnet. To migrate: -```ini -[all:vars] -wireguard_network=10.99.0.0/24 -wireguard_server_ip=10.99.0.1 -``` - -Update peer `allowed_ips` accordingly and re-deploy. +```bash +# 1. On the LXD host: tear down the old WG instance. +sudo wg-quick down wg0 +sudo systemctl disable wg-quick@wg0 +sudo apt purge wireguard wireguard-tools -y +sudo rm -rf /etc/wireguard -### Changing the WireGuard port +# 2. Remove old UFW/iptables rules on the host. +sudo ufw status numbered # find any rules referencing 10.8.0.0/24 or wg0 +sudo ufw delete # delete each +# Also remove the old WIREGUARD VPN FORWARDING block from /etc/ufw/before.rules. -```ini -[all:vars] -wireguard_port=41820 +# 3. Verify the new inventory has wireguard_ip per app host (see Quick Start). +# 4. Re-deploy. +cd deploy/ +sudo ./deploy.sh ``` -Ensure the new port is open on any upstream firewalls or cloud security groups. +Old `10.8.0.0/24` client `.conf` files will not work — re-import the freshly generated `10.0.0.0/24` configs. ## Disabling WireGuard -Set `wireguard_enabled=false` in the inventory. This prevents the WireGuard tasks from running on subsequent playbook runs but does **not** tear down an existing WireGuard interface. To fully remove WireGuard: +Set `wireguard_enabled=false` in the inventory. Subsequent playbook runs skip WG tasks but do **not** tear down an existing hub container. To fully remove: ```bash -# On the host -sudo wg-quick down wg0 -sudo systemctl disable wg-quick@wg0 -sudo apt remove wireguard wireguard-tools -sudo rm -rf /etc/wireguard +sudo lxc stop wireguard && sudo lxc delete wireguard +sudo lxc network forward port remove lxdbr1 udp 51820 ``` -Then manually revert any UFW rule changes and re-enable proxy monitoring paths if needed by re-running the monitoring and proxy roles. +Then revert any UFW lockdown rules and re-enable proxy monitoring paths if needed by re-running the monitoring and proxy roles. From 13adeea27979a9c0eff30a75eb9f1b32b5af4dbd Mon Sep 17 00:00:00 2001 From: 0xafrogeek Date: Tue, 26 May 2026 11:54:28 +0000 Subject: [PATCH 12/14] feat: enhance WireGuard deployment with new playbooks and lockdown features - Introduced `playbooks/wireguard.yml` for standalone WireGuard mesh setup, allowing for easier debugging and management. - Added `playbooks/wireguard-lockdown.yml` to restrict access to Grafana, Prometheus, Munin, Glowroot, and PostgreSQL to the VPN subnet. - Updated `dhis2.yml` to auto-import the new playbooks, streamlining the deployment process. - Refactored instance configuration templates to conditionally remove Glowroot proxy blocks based on lockdown settings. - Adjusted role defaults and tasks to improve clarity and maintainability, including disabling legacy monitoring lockdown behavior. --- deploy/ansible.cfg | 5 + deploy/dhis2.yml | 36 ++-- deploy/inventory/group_vars/all/vars.yml | 10 +- deploy/inventory/hosts.template | 10 - deploy/playbooks/wireguard-lockdown.yml | 102 ++++++++++ deploy/playbooks/wireguard.yml | 101 +++++++++ .../templates/apache2/instance.j2 | 2 +- .../templates/nginx/instance.j2 | 2 +- deploy/roles/wireguard/defaults/main.yml | 2 +- .../roles/wireguard/meta/argument_specs.yml | 25 ++- .../wireguard/tasks/host_portforward.yml | 9 - .../wireguard/tasks/lockdown_instances.yml | 12 +- .../wireguard/tasks/lockdown_monitor.yml | 34 +--- .../wireguard/tasks/lockdown_postgres.yml | 8 - .../roles/wireguard/tasks/lockdown_proxy.yml | 17 +- deploy/roles/wireguard/tasks/main.yml | 61 ++---- deploy/roles/wireguard/tasks/peer.yml | 6 + docs/WireGuard-VPN.md | 192 ++++++++++++++---- 18 files changed, 453 insertions(+), 181 deletions(-) create mode 100644 deploy/playbooks/wireguard-lockdown.yml create mode 100644 deploy/playbooks/wireguard.yml diff --git a/deploy/ansible.cfg b/deploy/ansible.cfg index 53ace9a..eafe242 100644 --- a/deploy/ansible.cfg +++ b/deploy/ansible.cfg @@ -6,6 +6,11 @@ host_key_checking = False # log_path=./deploy.log remote_tmp = /tmp/ local_tmp = ~/.ansible/tmp +# Explicit so sub-playbooks under playbooks/ resolve roles and custom +# filters/library regardless of which playbook is invoked. +roles_path = ./roles +filter_plugins = ./filter_plugins +library = ./library # callbacks_enabled = timer, profile_tasks # stdout_callback = yaml diff --git a/deploy/dhis2.yml b/deploy/dhis2.yml index b28b943..4c7331a 100644 --- a/deploy/dhis2.yml +++ b/deploy/dhis2.yml @@ -2,29 +2,15 @@ - name: Preparing localhost hosts: 127.0.0.1 become: true - gather_facts: true - roles: - - role: pre-install - - role: wireguard - vars: - wireguard_stage: host_portforward - tags: [wireguard] - -- name: WireGuard hub container - hosts: "{{ wireguard_hub_inventory_hostname | default('wireguard') }}" - become: true gather_facts: false roles: - - role: wireguard - vars: - wireguard_stage: hub - tags: [wireguard] + - role: pre-install - name: DHIS2 setup gather_facts: false force_handlers: true become: true - hosts: "all:!127.0.0.1:!{{ wireguard_hub_inventory_hostname | default('wireguard') }}" + hosts: all:!127.0.0.1:!{{ wireguard_hub_inventory_hostname | default('wireguard') }} vars_files: - vars/vars.yml roles: @@ -37,10 +23,6 @@ - role: create-instance tags: [create-instance] when: instance_state is not defined or instance_state != 'deleted' - - role: wireguard - vars: - wireguard_stage: peer - tags: [wireguard] tasks: - name: Install and configure unattended_upgrades ansible.builtin.include_tasks: playbooks/unattended_upgrades.yml @@ -62,5 +44,19 @@ roles: - role: backups +- name: WireGuard VPN bring-up + ansible.builtin.import_playbook: playbooks/wireguard.yml + +# Lockdown is auto-imported. Every play inside gates on wireguard_enabled, +# so this is a no-op when WireGuard is disabled. To deploy the mesh without +# locking services down (e.g. during initial cut-over while not all admins +# are on the VPN yet), run: +# ansible-playbook dhis2.yml --skip-tags wireguard-lockdown +# To revert a specific component without disabling WireGuard, use the +# per-component skip-tags listed in docs/WireGuard-VPN.md (lockdown-proxy, +# lockdown-monitor, lockdown-postgres, lockdown-instances). +- name: WireGuard VPN service lockdown + ansible.builtin.import_playbook: playbooks/wireguard-lockdown.yml + - import_playbook: playbooks/delete-dhis2-instance.yml tags: [never, delete-instance] diff --git a/deploy/inventory/group_vars/all/vars.yml b/deploy/inventory/group_vars/all/vars.yml index c4df056..3689144 100644 --- a/deploy/inventory/group_vars/all/vars.yml +++ b/deploy/inventory/group_vars/all/vars.yml @@ -4,17 +4,11 @@ # inventory's inline `wireguard_ip` and must NOT be listed here. # The hub itself uses 10.0.0.1; assign human peers from 10.0.0.6 upward # (10.0.0.2–.5 are reserved for app containers in the default inventory). -# -# With wireguard_auto_generate_keys: true (default), only name and allowed_ips -# are required. Keys are generated hub-side automatically. -# -# To manage keys manually, set wireguard_auto_generate_keys: false and provide -# a public_key for each peer. + # # Optional pg_access: a list of {database, user} entries that produces per-peer # pg_hba.conf rules on the databases host. Peers without pg_access have no -# PostgreSQL access. Database/user names must match ^[a-zA-Z0-9_]+$ (the -# PostgreSQL keyword 'all' is allowed). +# PostgreSQL access. wireguard_peers: - name: sysadmin allowed_ips: "10.0.0.6/32" diff --git a/deploy/inventory/hosts.template b/deploy/inventory/hosts.template index 211abe6..5ec9785 100644 --- a/deploy/inventory/hosts.template +++ b/deploy/inventory/hosts.template @@ -58,17 +58,7 @@ app_monitoring=glowroot # WireGuard VPN — restricts Grafana, Prometheus, Munin, Glowroot and PostgreSQL to VPN-only access. # App containers above are auto-derived as WG peers via inline wireguard_ip. # Human/admin peers (laptops, home machines) live in group_vars/all/vars.yml. -# Hosts in groups other than [web]/[databases]/[instances]/[monitoring]/[wireguard] -# (e.g. [backup_servers], [integration]) are skipped — they don't need wireguard_ip. -# -# wireguard_endpoint_listen — host-side IP `lxc network forward` binds on. -# Empty = auto-detect via ansible_default_ipv4.address. Must be on the host. -# wireguard_endpoint_public — what home peers dial in their .conf Endpoint. -# Empty = same as wireguard_endpoint_listen. On cloud VMs with 1:1 NAT -# (AWS EIP, GCP external IP) you MUST set this to the public IP/hostname. wireguard_enabled=false -# wireguard_endpoint_listen= -# wireguard_endpoint_public= # lxd diff --git a/deploy/playbooks/wireguard-lockdown.yml b/deploy/playbooks/wireguard-lockdown.yml new file mode 100644 index 0000000..299492e --- /dev/null +++ b/deploy/playbooks/wireguard-lockdown.yml @@ -0,0 +1,102 @@ +--- +# WireGuard VPN lockdown. +# +# Restricts Grafana, Prometheus, Munin, Glowroot and PostgreSQL access to +# the WireGuard subnet only. Auto-imported by dhis2.yml immediately after +# playbooks/wireguard.yml, so a single `deploy.sh` / `dhis2.yml` run with +# `wireguard_enabled=true` brings up the mesh AND locks services down in +# one go. May also be run standalone to (re-)apply lockdown after manual +# UFW changes: +# ansible-playbook playbooks/wireguard-lockdown.yml +# +# Every play below gates on wireguard_enabled, so this is a no-op when +# WireGuard is disabled — that's what lets dhis2.yml import it +# unconditionally. +# +# Selective skip via --skip-tags (works whether run via dhis2.yml or +# standalone): +# ansible-playbook dhis2.yml --skip-tags lockdown-postgres +# ansible-playbook dhis2.yml --skip-tags wireguard-lockdown # all of it + +- name: WireGuard lockdown | Pre-flight + hosts: 127.0.0.1 + connection: local + become: true + gather_facts: false + tags: + - wireguard + - wireguard-lockdown + tasks: + # Informational only — surfaces "lockdown is about to run" in the play + # output. When wireguard_enabled is false the rest of this playbook + # short-circuits via per-play `when:` guards. + - name: Lockdown | Announce service hardening + ansible.builtin.debug: + msg: >- + WireGuard service lockdown is about to apply: monitoring, + PostgreSQL, and Glowroot will be restricted to the + {{ wireguard_network | default('10.0.0.0/24') }} VPN subnet. + Skip with --skip-tags wireguard-lockdown (whole phase) or + --skip-tags lockdown-proxy,lockdown-monitor,lockdown-postgres,lockdown-instances + (per component). + when: wireguard_enabled | default(false) | bool + +- name: WireGuard lockdown | Proxy + hosts: web + become: true + gather_facts: false + tags: + - wireguard + - wireguard-lockdown + - lockdown-proxy + tasks: + - name: Lockdown | Proxy + ansible.builtin.include_role: + name: wireguard + tasks_from: lockdown_proxy.yml + when: wireguard_enabled | default(false) | bool + +- name: WireGuard lockdown | Monitoring + hosts: monitoring + become: true + gather_facts: false + tags: + - wireguard + - wireguard-lockdown + - lockdown-monitor + tasks: + - name: Lockdown | Monitor + ansible.builtin.include_role: + name: wireguard + tasks_from: lockdown_monitor.yml + when: wireguard_enabled | default(false) | bool + +- name: WireGuard lockdown | PostgreSQL + hosts: databases + become: true + gather_facts: false + tags: + - wireguard + - wireguard-lockdown + - lockdown-postgres + tasks: + - name: Lockdown | Postgres + ansible.builtin.include_role: + name: wireguard + tasks_from: lockdown_postgres.yml + when: wireguard_enabled | default(false) | bool + +- name: WireGuard lockdown | Instances + hosts: instances + become: true + gather_facts: false + tags: + - wireguard + - wireguard-lockdown + - lockdown-instances + tasks: + - name: Lockdown | Instances + ansible.builtin.include_role: + name: wireguard + tasks_from: lockdown_instances.yml + when: wireguard_enabled | default(false) | bool diff --git a/deploy/playbooks/wireguard.yml b/deploy/playbooks/wireguard.yml new file mode 100644 index 0000000..c8e295d --- /dev/null +++ b/deploy/playbooks/wireguard.yml @@ -0,0 +1,101 @@ +--- +# WireGuard VPN bring-up — standalone playbook. +# +# Brings up the WireGuard mesh in three independent stages so each can be +# debugged or re-run on its own: +# 1. host_portforward — provision the hub LXD container and the lxc network +# forward into it (LXD deployments only; SSH/distributed setups expose +# the hub VM directly via the operator's firewall). +# 2. hub — install/configure WireGuard server inside the hub. +# 3. peer — install/configure WireGuard on every app host. +# +# This playbook does NOT alter Grafana/Prometheus/Munin/Glowroot/PostgreSQL +# access rules. To restrict those services to VPN-only access, run +# `playbooks/wireguard-lockdown.yml` AFTER verifying the mesh works. +# +# Auto-imported from dhis2.yml when wireguard_enabled=true. May also be run +# directly: +# ansible-playbook playbooks/wireguard.yml + +- name: WireGuard | Provision hub on localhost + hosts: 127.0.0.1 + become: true + gather_facts: false + tags: + - wireguard + - wireguard-bring-up + tasks: + # Capture the inventory-level deployment mode BEFORE the local-connection + # bootstrap overrides ansible_connection. Used by downstream tasks to + # gate LXD-only behavior. Read via hostvars[] so the task's `vars:` + # override of ansible_connection doesn't poison the value we record. + - name: WireGuard | Capture deployment mode (lxd vs ssh) + vars: + ansible_connection: local + ansible.builtin.set_fact: + wireguard_deploy_mode: "{{ hostvars[inventory_hostname]['ansible_connection'] | default('lxd') }}" + + - name: WireGuard | Set Ansible connection to local + vars: + ansible_connection: local + ansible.builtin.set_fact: + ansible_connection: local + + - name: WireGuard | Gather network facts on host + ansible.builtin.setup: + gather_subset: + - network + - '!min' + + - name: WireGuard | Validate configuration + ansible.builtin.include_role: + name: wireguard + tasks_from: validate.yml + when: wireguard_enabled | default(false) | bool + + - name: WireGuard | Provision hub LXD container (LXD only) + ansible.builtin.include_role: + name: wireguard + tasks_from: lxd_container.yml + when: + - wireguard_enabled | default(false) | bool + - wireguard_deploy_mode == 'lxd' + + - name: WireGuard | LXD host port-forward (LXD only) + ansible.builtin.include_role: + name: wireguard + tasks_from: host_portforward.yml + when: + - wireguard_enabled | default(false) | bool + - wireguard_deploy_mode == 'lxd' + +- name: WireGuard | Configure hub server + hosts: "{{ wireguard_hub_inventory_hostname | default('wireguard') }}" + become: true + gather_facts: false + tags: + - wireguard + - wireguard-bring-up + tasks: + - name: WireGuard | Hub + ansible.builtin.include_role: + name: wireguard + tasks_from: hub.yml + when: wireguard_enabled | default(false) | bool + +- name: WireGuard | Configure peers across app hosts + hosts: "all:!127.0.0.1:!{{ wireguard_hub_inventory_hostname | default('wireguard') }}" + become: true + gather_facts: false + tags: + - wireguard + - wireguard-bring-up + tasks: + - name: WireGuard | Peer + ansible.builtin.include_role: + name: wireguard + tasks_from: peer.yml + when: + - wireguard_enabled | default(false) | bool + - hostvars[inventory_hostname].wireguard_ip is defined + - hostvars[inventory_hostname].wireguard_ip | length > 0 diff --git a/deploy/roles/create-instance/templates/apache2/instance.j2 b/deploy/roles/create-instance/templates/apache2/instance.j2 index fb16e8f..08c1204 100644 --- a/deploy/roles/create-instance/templates/apache2/instance.j2 +++ b/deploy/roles/create-instance/templates/apache2/instance.j2 @@ -1,5 +1,5 @@ {% set _glowroot_enabled = app_monitoring is defined and app_monitoring | trim == 'glowroot' %} -{% set _glowroot_locked = wireguard_enabled | default(false) | bool and wireguard_lockdown_monitoring | default(true) | bool %} +{% set _glowroot_locked = wireguard_enabled | default(false) | bool and wireguard_lockdown_monitoring | default(false) | bool %} {% if hostvars[item]['dhis2_base_path'] | default(item) | to_fixed_string == "ROOT" %} Require all granted diff --git a/deploy/roles/create-instance/templates/nginx/instance.j2 b/deploy/roles/create-instance/templates/nginx/instance.j2 index 7690d2e..559b50f 100644 --- a/deploy/roles/create-instance/templates/nginx/instance.j2 +++ b/deploy/roles/create-instance/templates/nginx/instance.j2 @@ -1,5 +1,5 @@ {% set _glowroot_enabled = app_monitoring is defined and app_monitoring | trim == 'glowroot' %} -{% set _glowroot_locked = wireguard_enabled | default(false) | bool and wireguard_lockdown_monitoring | default(true) | bool %} +{% set _glowroot_locked = wireguard_enabled | default(false) | bool and wireguard_lockdown_monitoring | default(false) | bool %} {% if hostvars[item]['dhis2_base_path'] | default(item) | to_fixed_string == "ROOT" %} location / { proxy_pass http://{{hostvars[item]['ansible_host']+':8080' }}; diff --git a/deploy/roles/wireguard/defaults/main.yml b/deploy/roles/wireguard/defaults/main.yml index 17bd7d7..eef804a 100644 --- a/deploy/roles/wireguard/defaults/main.yml +++ b/deploy/roles/wireguard/defaults/main.yml @@ -36,4 +36,4 @@ wireguard_prune_orphans: false wireguard_client_config_dir: /etc/wireguard/clients wireguard_client_key_dir: /etc/wireguard/clients/keys -wireguard_lockdown_monitoring: true +wireguard_lockdown_monitoring: false diff --git a/deploy/roles/wireguard/meta/argument_specs.yml b/deploy/roles/wireguard/meta/argument_specs.yml index 3796cdb..650edf2 100644 --- a/deploy/roles/wireguard/meta/argument_specs.yml +++ b/deploy/roles/wireguard/meta/argument_specs.yml @@ -13,10 +13,13 @@ argument_specs: default: false wireguard_stage: description: >- - Per-play stage selector. Set by dhis2.yml when including the role. - One of: host_portforward (LXD host: provision wireguard container + - UDP 51820 forward), hub (the wireguard container itself), peer - (every app container). + Legacy per-play stage selector retained for backward compatibility + when the role is invoked the old way + (roles: [{ role: wireguard, vars: { wireguard_stage: ... } }]). + The current playbooks (playbooks/wireguard.yml, + playbooks/wireguard-lockdown.yml) call task files directly via + include_role tasks_from: and ignore this variable. + One of: host_portforward, hub, peer, or empty. type: str default: "" choices: ["", "host_portforward", "hub", "peer"] @@ -123,10 +126,18 @@ argument_specs: default: false wireguard_lockdown_monitoring: description: >- - When true, removes monitoring paths from the public proxy - and restricts monitoring/DB ports to VPN-only access. + Gates the `/glowroot` proxy block in + roles/create-instance/templates/{nginx,apache2}/instance.j2. + Default false. The proxy role's first render during `dhis2.yml` + keeps `/glowroot` present; `playbooks/wireguard-lockdown.yml` + (lockdown_proxy.yml), which is auto-imported by `dhis2.yml` + immediately after the mesh playbook, sets this to true via task + vars and re-renders the same files to strip the block. Net + result of a full `dhis2.yml` run with wireguard_enabled=true: + `/glowroot` is absent. To preserve `/glowroot` while keeping the + VPN, run with `--skip-tags lockdown-proxy`. type: bool - default: true + default: false grafana_port: description: "Grafana HTTP port — used for VPN-only UFW rules" type: int diff --git a/deploy/roles/wireguard/tasks/host_portforward.yml b/deploy/roles/wireguard/tasks/host_portforward.yml index eb04863..0f1e5df 100644 --- a/deploy/roles/wireguard/tasks/host_portforward.yml +++ b/deploy/roles/wireguard/tasks/host_portforward.yml @@ -41,8 +41,6 @@ changed_when: true when: wireguard_endpoint_listen_resolved not in _listen_addrs -# lxc network forward DNATs in PREROUTING — packets go to the container -# via FORWARD chain, never hit the host INPUT chain. No host UFW rule needed. - name: WireGuard | Add UDP forward to hub container vars: ansible_connection: local @@ -57,10 +55,3 @@ - wireguard_port_fwd.rc != 0 - "'already exists' not in wireguard_port_fwd.stderr" - "'Duplicate' not in wireguard_port_fwd.stderr" - -- name: WireGuard | Remove legacy host UFW UDP rule (no longer needed with lxc forward) - community.general.ufw: - rule: allow - port: "{{ wireguard_port | string }}" - proto: udp - delete: true diff --git a/deploy/roles/wireguard/tasks/lockdown_instances.yml b/deploy/roles/wireguard/tasks/lockdown_instances.yml index 9aff3a0..7b033d0 100644 --- a/deploy/roles/wireguard/tasks/lockdown_instances.yml +++ b/deploy/roles/wireguard/tasks/lockdown_instances.yml @@ -4,7 +4,7 @@ path: /etc/munin/munin-node.conf register: wireguard_munin_node_stat -- name: Allow Glowroot (4000/tcp) from VPN subnet +- name: Lockdown | Allow Glowroot (4000/tcp) from VPN subnet community.general.ufw: rule: allow port: '4000' @@ -16,15 +16,7 @@ - app_monitoring is defined - app_monitoring | trim == 'glowroot' -- name: Lockdown | Remove legacy Glowroot UFW rule (src=lxd_gateway_ip) - community.general.ufw: - rule: allow - port: '4000' - src: '{{ lxd_gateway_ip }}' - proto: tcp - delete: true - -- name: Allow munin-node (4949/tcp) from monitor container only +- name: Lockdown | Allow munin-node (4949/tcp) from monitor container only community.general.ufw: rule: allow port: '4949' diff --git a/deploy/roles/wireguard/tasks/lockdown_monitor.yml b/deploy/roles/wireguard/tasks/lockdown_monitor.yml index efd294c..cf4d04c 100644 --- a/deploy/roles/wireguard/tasks/lockdown_monitor.yml +++ b/deploy/roles/wireguard/tasks/lockdown_monitor.yml @@ -45,7 +45,7 @@ path: /etc/prometheus/prometheus.yml register: wireguard_prometheus_stat -- name: Allow Grafana from VPN subnet +- name: Lockdown | Allow Grafana from VPN subnet community.general.ufw: rule: allow port: "{{ grafana_port }}" @@ -55,15 +55,7 @@ comment: 'Grafana — VPN access only' when: wireguard_grafana_ini_stat.stat.exists -- name: Lockdown | Remove legacy Grafana UFW rule (src=lxd_gateway_ip) - community.general.ufw: - rule: allow - port: "{{ grafana_port }}" - src: '{{ lxd_gateway_ip }}' - proto: tcp - delete: true - -- name: Remove proxy -> Grafana UFW rule +- name: Lockdown | Remove proxy -> Grafana UFW rule (replaced by VPN access) community.general.ufw: rule: allow port: "{{ grafana_port }}" @@ -75,7 +67,7 @@ label: '{{ item }}' when: wireguard_grafana_ini_stat.stat.exists -- name: Allow Prometheus (9090/tcp) from VPN subnet +- name: Lockdown | Allow Prometheus (9090/tcp) from VPN subnet community.general.ufw: rule: allow port: '9090' @@ -85,20 +77,12 @@ comment: 'Prometheus — VPN access only' when: wireguard_prometheus_stat.stat.exists -- name: Lockdown | Remove legacy Prometheus UFW rule (src=lxd_gateway_ip) - community.general.ufw: - rule: allow - port: '9090' - src: '{{ lxd_gateway_ip }}' - proto: tcp - delete: true - -- name: Check if Munin is installed +- name: Lockdown | Check if Munin is installed ansible.builtin.stat: path: /etc/munin/munin.conf register: wireguard_munin_conf_stat -- name: Allow Munin (80/tcp) from VPN subnet +- name: Lockdown | Allow Munin (80/tcp) from VPN subnet community.general.ufw: rule: allow port: '80' @@ -108,10 +92,14 @@ comment: 'Munin — VPN access only' when: wireguard_munin_conf_stat.stat.exists -- name: Lockdown | Remove legacy Munin UFW rule (src=lxd_gateway_ip) +- name: Lockdown | Remove proxy -> Munin UFW rule (replaced by VPN access) community.general.ufw: rule: allow port: '80' - src: '{{ lxd_gateway_ip }}' + src: "{{ hostvars[item]['ansible_host'] }}" proto: tcp delete: true + loop: "{{ groups.get('web', []) }}" + loop_control: + label: '{{ item }}' + when: wireguard_munin_conf_stat.stat.exists diff --git a/deploy/roles/wireguard/tasks/lockdown_postgres.yml b/deploy/roles/wireguard/tasks/lockdown_postgres.yml index 29dcd04..acd069e 100644 --- a/deploy/roles/wireguard/tasks/lockdown_postgres.yml +++ b/deploy/roles/wireguard/tasks/lockdown_postgres.yml @@ -36,14 +36,6 @@ notify: Reload PostgreSQL when: wireguard_pg_hba_path.stdout | trim | length > 0 -- name: Lockdown | Remove legacy blanket pg_hba VPN admin grant - ansible.builtin.lineinfile: - path: "{{ wireguard_pg_hba_path.stdout | trim }}" - regexp: '^hostssl\s+all\s+all\s+{{ lxd_gateway_ip | replace(".", "\\.") }}/32\s' - state: absent - notify: Reload PostgreSQL - when: wireguard_pg_hba_path.stdout | trim | length > 0 - - name: Lockdown | Allow PostgreSQL (5432/tcp) from VPN subnet community.general.ufw: rule: allow diff --git a/deploy/roles/wireguard/tasks/lockdown_proxy.yml b/deploy/roles/wireguard/tasks/lockdown_proxy.yml index 9a5b7b6..ba80187 100644 --- a/deploy/roles/wireguard/tasks/lockdown_proxy.yml +++ b/deploy/roles/wireguard/tasks/lockdown_proxy.yml @@ -21,14 +21,23 @@ - proxy | default('nginx') == 'nginx' - wireguard_nginx_monitoring_configs.files | default([]) | length > 0 +# `role_path` is the path to the currently-executing role (wireguard). +# Using it (rather than `playbook_dir`) keeps the cross-role template +# reference correct whether this file is loaded from dhis2.yml or from +# playbooks/wireguard-lockdown.yml. - name: Re-render nginx instance configs without Glowroot proxy blocks ansible.builtin.template: - src: '{{ playbook_dir }}/roles/create-instance/templates/nginx/instance.j2' + src: '{{ role_path }}/../create-instance/templates/nginx/instance.j2' dest: '/etc/nginx/conf.d/upstream/{{ item | to_fixed_string }}.conf' owner: root group: root mode: '0640' loop: '{{ groups["instances"] }}' + vars: + # Forces instance.j2 to drop the /glowroot location block. The role default + # is false (so a normal dhis2.yml proxy render keeps glowroot); the lockdown + # phase explicitly opts in here. + wireguard_lockdown_monitoring: true notify: Reload Nginx when: - proxy | default('nginx') == 'nginx' @@ -57,12 +66,16 @@ - name: Re-render apache2 instance configs without Glowroot proxy blocks ansible.builtin.template: - src: '{{ playbook_dir }}/roles/create-instance/templates/apache2/instance.j2' + src: '{{ role_path }}/../create-instance/templates/apache2/instance.j2' dest: '/etc/apache2/upstream/{{ item | to_fixed_string }}.conf' owner: root group: root mode: '0640' loop: '{{ groups["instances"] }}' + vars: + # Forces instance.j2 to drop the /glowroot Location block. See the nginx + # task above for why this is task-scoped rather than a play default. + wireguard_lockdown_monitoring: true notify: Reload Apache2 when: - proxy | default('nginx') == 'apache2' diff --git a/deploy/roles/wireguard/tasks/main.yml b/deploy/roles/wireguard/tasks/main.yml index 059bb64..f8e8f28 100644 --- a/deploy/roles/wireguard/tasks/main.yml +++ b/deploy/roles/wireguard/tasks/main.yml @@ -1,11 +1,18 @@ --- -# Stage dispatcher. The dhis2.yml playbook calls this role three times with -# different `wireguard_stage` values: -# 1. host_portforward — on 127.0.0.1: provision the wireguard LXD container -# and forward UDP {{ wireguard_port }} from the host's public IP. -# 2. hub — inside the wireguard container: keys, config, peers. -# 3. peer — inside each app container: pull config from hub, -# start wg-quick, apply per-host lockdown rules. +# The role is normally driven from playbooks/wireguard.yml and +# playbooks/wireguard-lockdown.yml via include_role tasks_from:, so this +# dispatcher is only used when the role is invoked the legacy way +# (roles: [{ role: wireguard, vars: { wireguard_stage: ... } }]). +# +# Stages: +# host_portforward — provision hub LXD container + lxc network forward +# (LXD deployments only). +# hub — install/configure WireGuard server inside the hub. +# peer — install/configure WireGuard on each app host. +# +# Lockdown stages (lockdown_proxy, lockdown_monitor, lockdown_postgres, +# lockdown_instances) are NOT exposed here; run +# playbooks/wireguard-lockdown.yml explicitly after the mesh is verified. - name: WireGuard | Validate configuration ansible.builtin.include_tasks: validate.yml @@ -15,17 +22,19 @@ tags: - always -- name: WireGuard | Provision hub LXD container +- name: WireGuard | Provision hub LXD container (LXD only) ansible.builtin.include_tasks: lxd_container.yml when: - wireguard_enabled | bool - wireguard_stage == 'host_portforward' + - ansible_connection | default('lxd') == 'lxd' -- name: WireGuard | LXD host port-forward +- name: WireGuard | LXD host port-forward (LXD only) ansible.builtin.include_tasks: host_portforward.yml when: - wireguard_enabled | bool - wireguard_stage == 'host_portforward' + - ansible_connection | default('lxd') == 'lxd' - name: WireGuard | Hub container setup ansible.builtin.include_tasks: hub.yml @@ -33,8 +42,6 @@ - wireguard_enabled | bool - wireguard_stage == 'hub' -# Hosts without wireguard_ip (backup_servers, integration) are not enrolled -# as peers; the hub never renders a config for them. - name: WireGuard | Peer setup ansible.builtin.include_tasks: peer.yml when: @@ -42,35 +49,3 @@ - wireguard_stage == 'peer' - hostvars[inventory_hostname].wireguard_ip is defined - hostvars[inventory_hostname].wireguard_ip | length > 0 - -- name: WireGuard | Lock down proxy - ansible.builtin.include_tasks: lockdown_proxy.yml - when: - - wireguard_enabled | bool - - wireguard_lockdown_monitoring | bool - - wireguard_stage == 'peer' - - inventory_hostname in groups.get('web', []) - -- name: WireGuard | Lock down monitoring - ansible.builtin.include_tasks: lockdown_monitor.yml - when: - - wireguard_enabled | bool - - wireguard_lockdown_monitoring | bool - - wireguard_stage == 'peer' - - inventory_hostname in groups.get('monitoring', []) - -- name: WireGuard | Lock down PostgreSQL - ansible.builtin.include_tasks: lockdown_postgres.yml - when: - - wireguard_enabled | bool - - wireguard_lockdown_monitoring | bool - - wireguard_stage == 'peer' - - inventory_hostname in groups.get('databases', []) - -- name: WireGuard | Lock down instances - ansible.builtin.include_tasks: lockdown_instances.yml - when: - - wireguard_enabled | bool - - wireguard_lockdown_monitoring | bool - - wireguard_stage == 'peer' - - inventory_hostname in groups.get('instances', []) diff --git a/deploy/roles/wireguard/tasks/peer.yml b/deploy/roles/wireguard/tasks/peer.yml index 9b86f93..254bfe5 100644 --- a/deploy/roles/wireguard/tasks/peer.yml +++ b/deploy/roles/wireguard/tasks/peer.yml @@ -19,10 +19,16 @@ group: root mode: '0700' +# `ansible_lxd_host` is set in inventory to `{{ inventory_hostname }}`, but +# under delegate_to that template still resolves with the ORIGINAL host's +# inventory_hostname (peer name) rather than the delegate target. Override +# explicitly so the LXD connection plugin execs into the hub container. - name: Peer | Slurp pre-rendered config from hub ansible.builtin.slurp: src: "{{ wireguard_client_config_dir }}/{{ inventory_hostname }}.conf" delegate_to: "{{ wireguard_hub_inventory_hostname }}" + vars: + ansible_lxd_host: "{{ wireguard_hub_inventory_hostname }}" register: _wireguard_peer_conf no_log: true diff --git a/docs/WireGuard-VPN.md b/docs/WireGuard-VPN.md index d22d776..f13f48d 100644 --- a/docs/WireGuard-VPN.md +++ b/docs/WireGuard-VPN.md @@ -2,6 +2,15 @@ WireGuard provides a secure VPN tunnel for administering DHIS2 infrastructure. The hub runs in its own LXD container; every app container (proxy, postgres, dhis, monitor) joins as a WG peer with its own `wg0` interface. Home/admin peers (sysadmin laptops) connect to the hub via the LXD host's public IP over UDP `51820`. Public DHIS2 web access is unaffected. +WireGuard is set up by `dhis2.yml` in a single deploy, in two stages run back-to-back: + +1. **Mesh bring-up** — `playbooks/wireguard.yml`. Creates the hub container (LXD only), installs WireGuard everywhere, and connects every peer. +2. **Service lockdown** — `playbooks/wireguard-lockdown.yml`. Restricts Grafana, Prometheus, Munin, Glowroot and PostgreSQL to the VPN subnet only. + +Both are imported by `dhis2.yml` and gated on `wireguard_enabled`, so a single `sudo ./deploy.sh` (or `ansible-playbook dhis2.yml`) takes you all the way to a hardened deployment. SSH on port 22 and the DHIS2 web app on 80/443 are deliberately left public. + +If you need the mesh without the firewall (e.g. during cut-over, while not all admins are on the VPN yet), skip lockdown with `--skip-tags wireguard-lockdown` — see [Skipping or reverting the lockdown](#skipping-or-reverting-the-lockdown). + ## Architecture ``` @@ -68,7 +77,13 @@ wireguard_endpoint_public=203.0.113.42 # public IP or DNS name ### 1. Configure the inventory -Edit `deploy/inventory/hosts` and set: +Copy the template, lock down its permissions (it ends up holding peer IP allocations and, optionally, manually-supplied keys), and set the master switch: + +```bash +cp deploy/inventory/hosts.template deploy/inventory/hosts +``` + +Then edit `deploy/inventory/hosts` and set: ```ini [all:vars] @@ -90,7 +105,9 @@ dhis ansible_host=172.19.2.11 ... wireguard_ip=10.0.0.4 [monitoring] monitor ansible_host=172.19.2.30 wireguard_ip=10.0.0.5 -[wireguard] +# Group is wireguard_hub (not "wireguard") to avoid Ansible's +# "host and group share name" warning. +[wireguard_hub] wireguard ansible_host=172.19.2.200 wireguard_ip=10.0.0.1 ``` @@ -116,21 +133,30 @@ Each peer needs only a **name** and **IP address**. Keypairs are generated hub-s ```bash cd deploy/ -# Full deployment (includes WireGuard) +# Full deployment: DHIS2 setup + WireGuard mesh + service lockdown. +# dhis2.yml imports playbooks/wireguard.yml and then +# playbooks/wireguard-lockdown.yml at the end. sudo ./deploy.sh -# Or deploy only WireGuard on an existing setup -sudo ansible-playbook dhis2.yml --tags wireguard +# Or bring up the VPN only (mesh + lockdown) on an already-deployed cluster: +sudo ansible-playbook playbooks/wireguard.yml \ + && sudo ansible-playbook playbooks/wireguard-lockdown.yml + +# Deploy the mesh without the lockdown (rare; e.g. mid-cutover): +sudo ansible-playbook dhis2.yml --skip-tags wireguard-lockdown ``` -The playbook will: -- Provision the `wireguard` LXD container at `172.19.2.200`. -- Add an `lxc network forward` rule so UDP `51820` from the host's public IP lands inside the wireguard container. +A `wireguard_enabled=true` run of `dhis2.yml` will: + +- Provision the `wireguard` LXD container at `172.19.2.200` (LXD setups only — skipped automatically on SSH/distributed deployments). +- Add an `lxc network forward` rule so UDP `51820` from the host's public IP lands inside the wireguard container (LXD only). - Install WireGuard packages inside the hub and inside every app container. - Generate hub + peer keypairs (preserved across runs). - Render `wg0.conf` for the hub and per-peer `.conf` files for every container and every human peer. - Pull each app container's config from the hub via Ansible `slurp` and start `wg-quick@wg0`. -- Lock down monitoring and database access to VPN-only. +- Restrict Grafana, Prometheus, Munin, Glowroot and PostgreSQL to the VPN subnet, and strip the `/glowroot` block from the public proxy. + +SSH on port 22 and the DHIS2 web app on 80/443 are deliberately left public. To peel off individual hardening steps (for debugging, mid-cutover, or operator preference), see [Skipping or reverting the lockdown](#skipping-or-reverting-the-lockdown). ### 4. Retrieve and import a human peer config @@ -154,30 +180,68 @@ sudo wg-quick up /path/to/sysadmin.conf # macOS / Windows # Import the .conf file into the WireGuard app -# Mobile (generate QR code on the hub) -sudo lxc exec wireguard -- qrencode -t ansiutf8 < /etc/wireguard/clients/sysadmin.conf +# Mobile (generate QR code on the hub). +# qrencode is not pulled in by wireguard-tools — install it inside the hub +# container first. The redirection must run inside the container, hence +# `bash -c '...'`; a top-level `<` would be parsed by the LXD host shell +# and fail because the .conf file lives inside the container. +sudo lxc exec wireguard -- apt-get install -y qrencode +sudo lxc exec wireguard -- bash -c \ + 'qrencode -t ansiutf8 < /etc/wireguard/clients/sysadmin.conf' ``` -### 5. Verify connectivity +### 5. Verify deployment + +A full `dhis2.yml` run brings the mesh up *and* applies lockdown. Verify both: + +**Mesh health (run regardless of lockdown):** ```bash +# On the LXD host (only meaningful for LXD deployments): +sudo lxc exec wireguard -- wg show # all peers + recent handshakes +sudo lxc exec proxy -- wg show +sudo lxc network forward show lxdbr1 # confirms UDP 51820 forward + # On the home machine (connected over WG): -ping 10.0.0.1 # hub -ping 10.0.0.5 # monitor (via mesh) -curl http://10.0.0.5:3000 # Grafana (locked-down, VPN-only) -psql -h 10.0.0.3 -U dhis -d dhis2 +ping 10.0.0.1 # hub +ping 10.0.0.5 # monitor via mesh relay +``` -# On the LXD host: -sudo lxc exec wireguard -- wg show -sudo lxc exec proxy -- wg show -sudo lxc network forward show lxdbr1 # confirms UDP 51820 forward +**Lockdown effects (skip this block if you ran with `--skip-tags wireguard-lockdown`):** + +```bash +# From the LXD host (not on the VPN) — should fail / time out. +curl -m 3 http://172.19.2.30:3000/ # Grafana — was reachable, now blocked +curl -m 3 http://172.19.2.11:4000/ # Glowroot — was reachable, now blocked + +# From a connected WG peer (e.g. 10.0.0.6): +curl -m 3 http://10.0.0.5:3000/ # Grafana via VPN +curl -m 3 http://10.0.0.4:4000/ # Glowroot via VPN +psql -h 10.0.0.3 -U dhis -d dhis2 # only if pg_access is set for this peer + +# DHIS2 itself stays public — sanity check it hasn't moved: +curl -I https://your.dhis2.fqdn/ # expect 200 / 302 ``` -## What gets locked down +If the lockdown checks fail but the mesh checks pass, the most likely cause is a misconfigured `wireguard_endpoint_public` (cloud 1:1 NAT) or UDP `51820` blocked at the cloud security group — see [Troubleshooting](#troubleshooting). To recover monitoring access while you debug, run `sudo ansible-playbook dhis2.yml --skip-tags wireguard-lockdown` to peel lockdown off without tearing down the mesh. + +## Service lockdown + +The lockdown stage runs automatically as part of `dhis2.yml` whenever `wireguard_enabled=true`. It is idempotent — re-running with no inventory changes does nothing — and can also be invoked standalone to re-apply after manual UFW edits: + +```bash +cd deploy/ + +# Dry-run the lockdown stage on its own (mesh assumed already up). +sudo ansible-playbook playbooks/wireguard-lockdown.yml --check --diff -When `wireguard_enabled=true` and `wireguard_lockdown_monitoring=true` (default): +# Re-apply standalone (rarely needed; dhis2.yml already does this). +sudo ansible-playbook playbooks/wireguard-lockdown.yml +``` + +### What gets locked down -| Service | Container | Port | Before VPN | After VPN | +| Service | Container | Port | Before lockdown | After lockdown | |---|---|---|---|---| | Grafana | monitor | 3000 | Accessible via proxy `/grafana` | VPN-only (`10.0.0.5:3000`) | | Prometheus | monitor | 9090 | Accessible via proxy | VPN-only | @@ -186,7 +250,28 @@ When `wireguard_enabled=true` and `wireguard_lockdown_monitoring=true` (default) | munin-node | dhis instances | 4949 | Accessible from monitor container | Monitor container only | | PostgreSQL | postgres | 5432 | LXD network only | VPN-only, per-peer rules | -**Not affected**: DHIS2 web app on ports 80/443 through the proxy stays public. +**Not affected**: SSH on port 22 and the DHIS2 web app on ports 80/443 stay public. The lockdown only touches operational/admin services. + +### Per-component control + +Each lockdown step has its own tag so you can opt into or out of a subset. Tags work whether the playbook runs via `dhis2.yml` or standalone: + +| Tag | Effect | +|---|---| +| `lockdown-proxy` | Empties monitoring upstream configs on nginx/apache; re-renders DHIS2 vhosts without `/glowroot` blocks | +| `lockdown-monitor` | UFW rules: allow Grafana/Prometheus/Munin from VPN subnet only; remove proxy → Grafana and proxy → Munin rules | +| `lockdown-postgres` | Per-peer `pg_hba.conf` rules from `wireguard_peers[*].pg_access`; UFW rule allowing 5432 from VPN subnet | +| `lockdown-instances` | UFW rule allowing Glowroot 4000 from VPN subnet; munin-node 4949 restricted to monitor container | +| `wireguard-lockdown` | Umbrella tag matching all four of the above (used by `--skip-tags wireguard-lockdown`) | + +```bash +# Deploy with the mesh up but PostgreSQL still LXD-only (e.g. while a +# remote DBA hasn't been onboarded to the VPN yet): +sudo ansible-playbook dhis2.yml --skip-tags lockdown-postgres + +# Re-run only the proxy lockdown after editing inventory: +sudo ansible-playbook playbooks/wireguard-lockdown.yml --tags lockdown-proxy +``` ### PostgreSQL VPN access @@ -213,17 +298,31 @@ If a peer's `allowed_ips` routes additional networks (comma-separated CIDRs), se App-level pg_hba entries (added by the `create-instance` role) are unaffected — they continue to work over the LXD bridge. -The role manages all `pg_access`-derived rules inside a single `blockinfile` block delimited by `# BEGIN/END ANSIBLE MANAGED — wireguard per-peer pg_access`. Removing a peer (or its `pg_access` entry) and re-running the role removes the corresponding `hostssl` line. A blanket `host all all /32` grant added by older versions of this role is removed on upgrade. +The role manages all `pg_access`-derived rules inside a single `blockinfile` block delimited by `# BEGIN/END ANSIBLE MANAGED — wireguard per-peer pg_access`. Removing a peer (or its `pg_access` entry) and re-running `playbooks/wireguard-lockdown.yml` removes the corresponding `hostssl` line. -### Restoring public monitoring access +### Skipping or reverting the lockdown -Set `wireguard_lockdown_monitoring: false` and re-run: +Because lockdown is now part of `dhis2.yml`, "skipping" and "reverting" are the same operation: tell `dhis2.yml` not to run the lockdown tag(s) you want to undo. The mesh is unaffected — only the firewall and proxy hardening flips back. ```bash -sudo ansible-playbook dhis2.yml --tags monitoring,proxy-install,wireguard +# Skip the whole lockdown for this run (mesh stays up, services revert +# to public). Idempotent: re-runs without the flag will re-lock them. +sudo ansible-playbook dhis2.yml --skip-tags wireguard-lockdown + +# Revert one component only — e.g. unlock PostgreSQL while a remote DBA +# joins the VPN, then drop the flag once they're on: +sudo ansible-playbook dhis2.yml --skip-tags lockdown-postgres +``` + +Important: skipping a lockdown tag on its own does **not** restore the original UFW rules / nginx vhost content — it just stops the lockdown tasks from running on that play. To re-create the pre-lockdown state, also re-run the role that originally produced those rules: + +```bash +# Restore proxy → monitoring UFW rules and the /glowroot proxy block: +sudo ansible-playbook dhis2.yml --tags monitoring,proxy-install \ + --skip-tags wireguard-lockdown ``` -The monitoring and proxy roles recreate the deleted config files idempotently. +To turn WireGuard off completely (mesh + lockdown), set `wireguard_enabled=false` in inventory and re-run `dhis2.yml`. The mesh plays no-op and the lockdown plays no-op — but again, services that were already locked down won't auto-revert; run the `dhis2.yml --tags monitoring,proxy-install --skip-tags wireguard-lockdown` recipe above to restore them. ## Configuration reference @@ -245,7 +344,7 @@ All variables are set in `deploy/roles/wireguard/defaults/main.yml` and can be o | `wireguard_client_config_dir` | `/etc/wireguard/clients` | Directory on the hub for peer configs | | `wireguard_client_key_dir` | `/etc/wireguard/clients/keys` | Directory on the hub for peer keys | | `wireguard_prune_orphans` | `false` | Remove files for peers no longer in inventory | -| `wireguard_lockdown_monitoring` | `true` | Restrict monitoring/DB to VPN-only | +| `wireguard_lockdown_monitoring` | `false` | Gates the `/glowroot` proxy block in `roles/create-instance/templates/{nginx,apache2}/instance.j2`. Default `false` keeps `/glowroot` reachable through the public proxy. `playbooks/wireguard-lockdown.yml` (`lockdown_proxy.yml`) sets it to `true` via task vars when re-rendering, which strips the block. Re-run `dhis2.yml --tags proxy-install` to revert. Not normally set in inventory | | `wireguard_peers` | `[]` | List of human/admin peers | ### Peer definition (human peers only) @@ -293,15 +392,20 @@ AllowedIPs = 0.0.0.0/0 ### Adding a new peer -Add the peer to `wireguard_peers` and re-run: +Add the peer to `wireguard_peers` (and a `pg_access` entry if they need database access), then re-run the full deploy: ```bash -sudo ansible-playbook dhis2.yml --tags wireguard +sudo ansible-playbook dhis2.yml ``` -Then retrieve the config from `/etc/wireguard/clients/.conf` inside the hub container. +The mesh stage adds the peer and applies the change with `wg syncconf` (no existing tunnels dropped); the lockdown stage immediately after picks up the new `pg_access` entries and writes them to `pg_hba.conf`. Retrieve the new peer's config from `/etc/wireguard/clients/.conf` inside the hub container. + +If you want to do just the WireGuard portion without re-running the rest of `dhis2.yml`: -The hub uses `wg syncconf` to apply peer changes **without dropping existing VPN sessions**. +```bash +sudo ansible-playbook playbooks/wireguard.yml \ + && sudo ansible-playbook playbooks/wireguard-lockdown.yml --tags lockdown-postgres +``` ### Removing a peer @@ -312,7 +416,7 @@ Remove the peer entry and re-run. Set `wireguard_prune_orphans: true` to also cl ```bash # On the LXD host, reach into the hub container: sudo lxc exec wireguard -- rm /etc/wireguard/clients/keys/sysadmin.{key,pub,psk} -sudo ansible-playbook dhis2.yml --tags wireguard +sudo ansible-playbook dhis2.yml ``` The affected peer must re-import their updated `.conf`. @@ -362,11 +466,23 @@ Old `10.8.0.0/24` client `.conf` files will not work — re-import the freshly g ## Disabling WireGuard -Set `wireguard_enabled=false` in the inventory. Subsequent playbook runs skip WG tasks but do **not** tear down an existing hub container. To fully remove: +Set `wireguard_enabled=false` in the inventory. Subsequent `dhis2.yml` runs no-op both the mesh and the lockdown — every play in `playbooks/wireguard.yml` and `playbooks/wireguard-lockdown.yml` gates on `wireguard_enabled`. + +This **stops future WireGuard changes** but does **not** tear down an existing hub container or revert UFW / `pg_hba.conf` / proxy edits already applied by previous lockdown runs. To fully remove: ```bash +# 1. Stop and remove the hub container (LXD setups only). sudo lxc stop wireguard && sudo lxc delete wireguard sudo lxc network forward port remove lxdbr1 udp 51820 -``` -Then revert any UFW lockdown rules and re-enable proxy monitoring paths if needed by re-running the monitoring and proxy roles. +# 2. Stop wg-quick on each app container. +for c in proxy postgres dhis monitor; do + sudo lxc exec "$c" -- systemctl disable --now wg-quick@wg0 + sudo lxc exec "$c" -- rm -rf /etc/wireguard +done + +# 3. Restore public access to services that the lockdown playbook +# locked down. Re-running the upstream DHIS2 roles re-creates the +# original UFW and proxy configs idempotently: +sudo ansible-playbook dhis2.yml --tags monitoring,proxy-install +``` From 68637f72a3ae4f3a944d08eac8418e2e953fc7be Mon Sep 17 00:00:00 2001 From: 0xafrogeek Date: Wed, 27 May 2026 19:20:40 +0000 Subject: [PATCH 13/14] fix: improve WireGuard deployment tasks and connection handling - Enhanced `wireguard.yml` to accurately capture the deployment mode from the wireguard hub host's hostvars, ensuring correct behavior in LXD environments. - Added a new task in `hub.yml` to wait for LXD connection readiness, improving reliability during the deployment process. - Introduced a wait task in `lxd_container.yml` for cloud-init completion, preventing race conditions that could lead to installation failures. --- deploy/playbooks/wireguard.yml | 15 ++++++++++----- deploy/roles/wireguard/tasks/hub.yml | 5 +++++ deploy/roles/wireguard/tasks/lxd_container.yml | 17 ++++++++++++++--- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/deploy/playbooks/wireguard.yml b/deploy/playbooks/wireguard.yml index c8e295d..55f6063 100644 --- a/deploy/playbooks/wireguard.yml +++ b/deploy/playbooks/wireguard.yml @@ -25,15 +25,20 @@ - wireguard - wireguard-bring-up tasks: - # Capture the inventory-level deployment mode BEFORE the local-connection - # bootstrap overrides ansible_connection. Used by downstream tasks to - # gate LXD-only behavior. Read via hostvars[] so the task's `vars:` - # override of ansible_connection doesn't poison the value we record. + # Capture the inventory-level deployment mode. We must read it from the + # wireguard hub host's hostvars (not from 127.0.0.1's), because when this + # playbook is imported by dhis2.yml the pre-install role has already + # set_fact'd ansible_connection=local on 127.0.0.1, which would make + # this read 'local' instead of the inventory's 'lxd'. The hub host's + # ansible_connection comes straight from [all:vars] and is never + # clobbered. - name: WireGuard | Capture deployment mode (lxd vs ssh) vars: ansible_connection: local ansible.builtin.set_fact: - wireguard_deploy_mode: "{{ hostvars[inventory_hostname]['ansible_connection'] | default('lxd') }}" + wireguard_deploy_mode: >- + {{ hostvars[wireguard_hub_inventory_hostname | default('wireguard')]['ansible_connection'] + | default('lxd') }} - name: WireGuard | Set Ansible connection to local vars: diff --git a/deploy/roles/wireguard/tasks/hub.yml b/deploy/roles/wireguard/tasks/hub.yml index 60cbc5e..a82d70b 100644 --- a/deploy/roles/wireguard/tasks/hub.yml +++ b/deploy/roles/wireguard/tasks/hub.yml @@ -1,4 +1,9 @@ --- +- name: Hub | Wait for LXD connection to be usable + ansible.builtin.wait_for_connection: + timeout: 60 + sleep: 3 + - name: Hub | Install WireGuard packages ansible.builtin.apt: name: diff --git a/deploy/roles/wireguard/tasks/lxd_container.yml b/deploy/roles/wireguard/tasks/lxd_container.yml index 97b7707..8919f04 100644 --- a/deploy/roles/wireguard/tasks/lxd_container.yml +++ b/deploy/roles/wireguard/tasks/lxd_container.yml @@ -30,8 +30,6 @@ ipv4.address: "{{ wireguard_hub_lxd_ip }}" register: wireguard_create_status -# Restart-on-create (not a handler) so the static IP takes effect within -# the same play. - name: WireGuard | Ensure hub container has its static IP # noqa: no-handler vars: ansible_connection: local @@ -48,8 +46,21 @@ lxc exec {{ wireguard_hub_inventory_hostname }} -- test -d /run/systemd/system changed_when: false - retries: 10 + retries: 30 delay: 2 register: wireguard_systemd_check until: wireguard_systemd_check.rc == 0 when: wireguard_create_status.changed or (wireguard_restart_status.changed | default(false)) + +- name: WireGuard | Wait for cloud-init to finish inside hub container + ansible.builtin.command: + cmd: >- + lxc exec {{ wireguard_hub_inventory_hostname }} -- + cloud-init status --wait + changed_when: false + failed_when: false + retries: 3 + delay: 5 + register: wireguard_cloud_init_check + until: wireguard_cloud_init_check.rc in [0, 2] + when: wireguard_create_status.changed or (wireguard_restart_status.changed | default(false)) From c03b77971096f3c4f7a021bca73abd1d503c9119 Mon Sep 17 00:00:00 2001 From: 0xafrogeek Date: Tue, 9 Jun 2026 11:55:34 +0000 Subject: [PATCH 14/14] fix: correct WireGuard peer connection and postgres lockdown on LXD --- deploy/inventory/group_vars/all/vars.yml | 19 +++-- deploy/inventory/hosts.template | 2 +- deploy/playbooks/wireguard-lockdown.yml | 4 +- deploy/playbooks/wireguard.yml | 24 +++++- deploy/roles/wireguard/defaults/main.yml | 2 +- .../roles/wireguard/meta/argument_specs.yml | 31 ++++---- .../wireguard/tasks/generate_client_keys.yml | 2 +- deploy/roles/wireguard/tasks/hub.yml | 4 +- .../wireguard/tasks/lockdown_instances.yml | 4 +- .../wireguard/tasks/lockdown_monitor.yml | 6 +- .../wireguard/tasks/lockdown_postgres.yml | 62 +++++++++++---- deploy/roles/wireguard/tasks/main.yml | 6 +- deploy/roles/wireguard/tasks/validate.yml | 53 ++++++++----- .../roles/wireguard/templates/client.conf.j2 | 2 +- deploy/roles/wireguard/templates/wg0.conf.j2 | 6 +- docs/WireGuard-VPN.md | 76 +++++++++---------- 16 files changed, 187 insertions(+), 116 deletions(-) diff --git a/deploy/inventory/group_vars/all/vars.yml b/deploy/inventory/group_vars/all/vars.yml index 3689144..e80c5b4 100644 --- a/deploy/inventory/group_vars/all/vars.yml +++ b/deploy/inventory/group_vars/all/vars.yml @@ -3,19 +3,26 @@ # App containers (proxy/postgres/dhis/monitor) are auto-derived from the # inventory's inline `wireguard_ip` and must NOT be listed here. # The hub itself uses 10.0.0.1; assign human peers from 10.0.0.6 upward -# (10.0.0.2–.5 are reserved for app containers in the default inventory). +# (10.0.0.2-.5 are reserved for app containers in the default inventory). # -# Optional pg_access: a list of {database, user} entries that produces per-peer -# pg_hba.conf rules on the databases host. Peers without pg_access have no -# PostgreSQL access. +# Optional pg_access: a list of entries that produce per-peer pg_hba.conf +# rules on the databases host. Peers without pg_access have no PostgreSQL +# access. Each entry is one of: +# - { instance: } preferred - derives both database and user +# from the DHIS2 instance's LXD container name +# (db name == role == owner == container name). +# Reference a host from the [instances] group so +# the rule tracks the instance instead of being +# hardcoded. +# - { database: all, user: all } explicit - for wildcard/superuser access. wireguard_peers: - name: sysadmin allowed_ips: "10.0.0.6/32" # public_key: "" # only needed if wireguard_auto_generate_keys: false - # preshared_key: "" # optional — for post-quantum resistance + # preshared_key: "" # optional - for post-quantum resistance pg_access: - - { database: dhis, user: dhis } # change to { database: all, user: all } to allow access to all databases as any user + - { instance: dhis } # access the 'dhis' instance's database as its own role # - name: superuser # allowed_ips: "10.0.0.7/32" # pg_access: diff --git a/deploy/inventory/hosts.template b/deploy/inventory/hosts.template index 5ec9785..da52ae5 100644 --- a/deploy/inventory/hosts.template +++ b/deploy/inventory/hosts.template @@ -55,7 +55,7 @@ postgresql_version=16 server_monitoring=munin app_monitoring=glowroot -# WireGuard VPN — restricts Grafana, Prometheus, Munin, Glowroot and PostgreSQL to VPN-only access. +# WireGuard VPN: restricts Grafana, Prometheus, Munin, Glowroot and PostgreSQL to VPN-only access. # App containers above are auto-derived as WG peers via inline wireguard_ip. # Human/admin peers (laptops, home machines) live in group_vars/all/vars.yml. wireguard_enabled=false diff --git a/deploy/playbooks/wireguard-lockdown.yml b/deploy/playbooks/wireguard-lockdown.yml index 299492e..3186ae7 100644 --- a/deploy/playbooks/wireguard-lockdown.yml +++ b/deploy/playbooks/wireguard-lockdown.yml @@ -10,7 +10,7 @@ # ansible-playbook playbooks/wireguard-lockdown.yml # # Every play below gates on wireguard_enabled, so this is a no-op when -# WireGuard is disabled — that's what lets dhis2.yml import it +# WireGuard is disabled - that's what lets dhis2.yml import it # unconditionally. # # Selective skip via --skip-tags (works whether run via dhis2.yml or @@ -27,7 +27,7 @@ - wireguard - wireguard-lockdown tasks: - # Informational only — surfaces "lockdown is about to run" in the play + # Informational only - surfaces "lockdown is about to run" in the play # output. When wireguard_enabled is false the rest of this playbook # short-circuits via per-play `when:` guards. - name: Lockdown | Announce service hardening diff --git a/deploy/playbooks/wireguard.yml b/deploy/playbooks/wireguard.yml index 55f6063..ca6e3e5 100644 --- a/deploy/playbooks/wireguard.yml +++ b/deploy/playbooks/wireguard.yml @@ -1,13 +1,13 @@ --- -# WireGuard VPN bring-up — standalone playbook. +# WireGuard VPN bring-up - standalone playbook. # # Brings up the WireGuard mesh in three independent stages so each can be # debugged or re-run on its own: -# 1. host_portforward — provision the hub LXD container and the lxc network +# 1. host_portforward - provision the hub LXD container and the lxc network # forward into it (LXD deployments only; SSH/distributed setups expose # the hub VM directly via the operator's firewall). -# 2. hub — install/configure WireGuard server inside the hub. -# 3. peer — install/configure WireGuard on every app host. +# 2. hub - install/configure WireGuard server inside the hub. +# 3. peer - install/configure WireGuard on every app host. # # This playbook does NOT alter Grafana/Prometheus/Munin/Glowroot/PostgreSQL # access rules. To restrict those services to VPN-only access, run @@ -96,6 +96,22 @@ - wireguard - wireguard-bring-up tasks: + # The backups role runs earlier in dhis2.yml on the databases host and + # does `set_fact: ansible_connection=local`, which persists for that host + # across every later play (set_fact is sticky). Without this re-assert the + # postgres peer's WireGuard tasks would run on the LXD host instead of + # inside the container, so its wg0 never comes up. Re-derive the real + # connection from the hub host's ansible_connection, which comes straight + # from [all:vars] and is never clobbered (same authoritative source used to + # capture wireguard_deploy_mode in the bring-up play above). No-op for + # SSH deployments and for peers that never ran the backups role. + - name: WireGuard | Restore peer connection (undo backups ansible_connection leak) + ansible.builtin.set_fact: + ansible_connection: >- + {{ hostvars[wireguard_hub_inventory_hostname | default('wireguard')]['ansible_connection'] + | default('lxd') }} + when: wireguard_enabled | default(false) | bool + - name: WireGuard | Peer ansible.builtin.include_role: name: wireguard diff --git a/deploy/roles/wireguard/defaults/main.yml b/deploy/roles/wireguard/defaults/main.yml index eef804a..7870084 100644 --- a/deploy/roles/wireguard/defaults/main.yml +++ b/deploy/roles/wireguard/defaults/main.yml @@ -6,7 +6,7 @@ wireguard_server_ip: "10.0.0.1" wireguard_port: 51820 wireguard_interface: wg0 -# Hub container — dedicated LXD container running the WireGuard server. +# Hub container - dedicated LXD container running the WireGuard server. wireguard_hub_inventory_hostname: "wireguard" wireguard_hub_lxd_ip: "172.19.2.200" diff --git a/deploy/roles/wireguard/meta/argument_specs.yml b/deploy/roles/wireguard/meta/argument_specs.yml index 650edf2..6d4e640 100644 --- a/deploy/roles/wireguard/meta/argument_specs.yml +++ b/deploy/roles/wireguard/meta/argument_specs.yml @@ -8,7 +8,7 @@ argument_specs: - Optionally locks down monitoring dashboards and PostgreSQL to VPN-only access. options: wireguard_enabled: - description: "Master switch — enable the WireGuard VPN role" + description: "Master switch - enable the WireGuard VPN role" type: bool default: false wireguard_stage: @@ -83,13 +83,17 @@ argument_specs: list them here. Each entry requires name and allowed_ips. public_key is required only when wireguard_auto_generate_keys is false. Optional: preshared_key. - Optional: peer_ip (single CIDR ending in /32) — used for per-peer + Optional: peer_ip (single CIDR ending in /32) - used for per-peer pg_hba.conf rules when allowed_ips routes additional networks. If omitted, the first CIDR in allowed_ips is used. - Optional: pg_access (list of {database, user}) — generates per-peer - pg_hba.conf entries on the databases host. Database and user names - must match ^[a-zA-Z0-9_]+$ (PostgreSQL keyword 'all' is allowed). - Peers without pg_access have no PostgreSQL access. + Optional: pg_access - generates per-peer pg_hba.conf entries on the + databases host. Each entry is either { instance: }, which + derives database and user from the named [instances] host's LXD + container name (db == role == owner == container name), or explicit + { database, user } for wildcards such as { database: all, user: all }. + Resolved database/user names must match ^[a-zA-Z0-9_]+$ (PostgreSQL + keyword 'all' is allowed). Peers without pg_access have no PostgreSQL + access. type: list elements: dict default: [] @@ -105,7 +109,7 @@ argument_specs: When true, generate a pre-shared key (PSK) for each peer that does not supply an explicit preshared_key, and embed it into both the hub and peer configs. Adds a symmetric post-quantum hedge. - Off by default — enabling on an existing deployment forces all + Off by default - enabling on an existing deployment forces all affected clients to re-import their .conf. type: bool default: false @@ -128,17 +132,12 @@ argument_specs: description: >- Gates the `/glowroot` proxy block in roles/create-instance/templates/{nginx,apache2}/instance.j2. - Default false. The proxy role's first render during `dhis2.yml` - keeps `/glowroot` present; `playbooks/wireguard-lockdown.yml` - (lockdown_proxy.yml), which is auto-imported by `dhis2.yml` - immediately after the mesh playbook, sets this to true via task - vars and re-renders the same files to strip the block. Net - result of a full `dhis2.yml` run with wireguard_enabled=true: - `/glowroot` is absent. To preserve `/glowroot` while keeping the - VPN, run with `--skip-tags lockdown-proxy`. + lockdown_proxy.yml sets this true to re-render the proxy configs + without `/glowroot`. Use `--skip-tags lockdown-proxy` to keep + `/glowroot` reachable while the VPN is enabled. type: bool default: false grafana_port: - description: "Grafana HTTP port — used for VPN-only UFW rules" + description: "Grafana HTTP port - used for VPN-only UFW rules" type: int default: 3000 diff --git a/deploy/roles/wireguard/tasks/generate_client_keys.yml b/deploy/roles/wireguard/tasks/generate_client_keys.yml index eba396d..ff00e7f 100644 --- a/deploy/roles/wireguard/tasks/generate_client_keys.yml +++ b/deploy/roles/wireguard/tasks/generate_client_keys.yml @@ -1,6 +1,6 @@ --- # Hub-side keypair + PSK generator. -# Caller passes `wg_keygen_peers` — a list of normalized peer dicts (name, +# Caller passes `wg_keygen_peers` - a list of normalized peer dicts (name, # allowed_ips, wireguard_ip, endpoint_kind, optional public_key/preshared_key). # Produces wireguard_peers_resolved enriched with public_key, _private_key, # and _preshared_key per entry. diff --git a/deploy/roles/wireguard/tasks/hub.yml b/deploy/roles/wireguard/tasks/hub.yml index a82d70b..ec45a07 100644 --- a/deploy/roles/wireguard/tasks/hub.yml +++ b/deploy/roles/wireguard/tasks/hub.yml @@ -71,7 +71,7 @@ WireGuard Hub Public Key: {{ wireguard_server_public_key_data.content | b64decode | trim }} -- name: Hub | Enable IPv4 forwarding (spoke ↔ spoke routing) +- name: Hub | Enable IPv4 forwarding (spoke to spoke routing) ansible.posix.sysctl: name: net.ipv4.ip_forward value: '1' @@ -79,7 +79,7 @@ reload: true sysctl_set: true -- name: Hub | Allow wg0 ↔ wg0 FORWARD (mesh relay) +- name: Hub | Allow wg0 to wg0 FORWARD (mesh relay) ansible.builtin.iptables: chain: FORWARD in_interface: '{{ wireguard_interface }}' diff --git a/deploy/roles/wireguard/tasks/lockdown_instances.yml b/deploy/roles/wireguard/tasks/lockdown_instances.yml index 7b033d0..d526d6e 100644 --- a/deploy/roles/wireguard/tasks/lockdown_instances.yml +++ b/deploy/roles/wireguard/tasks/lockdown_instances.yml @@ -11,7 +11,7 @@ src: '{{ wireguard_network }}' proto: tcp state: enabled - comment: 'Glowroot APM — VPN access only' + comment: 'Glowroot APM - VPN access only' when: - app_monitoring is defined - app_monitoring | trim == 'glowroot' @@ -23,7 +23,7 @@ src: "{{ hostvars[groups['monitoring'][0]]['ansible_host'] }}" proto: tcp state: enabled - comment: 'munin-node — monitor container access only' + comment: 'munin-node - monitor container access only' when: - wireguard_munin_node_stat.stat.exists - groups.get('monitoring', []) | length > 0 diff --git a/deploy/roles/wireguard/tasks/lockdown_monitor.yml b/deploy/roles/wireguard/tasks/lockdown_monitor.yml index cf4d04c..e306012 100644 --- a/deploy/roles/wireguard/tasks/lockdown_monitor.yml +++ b/deploy/roles/wireguard/tasks/lockdown_monitor.yml @@ -52,7 +52,7 @@ src: '{{ wireguard_network }}' proto: tcp state: enabled - comment: 'Grafana — VPN access only' + comment: 'Grafana - VPN access only' when: wireguard_grafana_ini_stat.stat.exists - name: Lockdown | Remove proxy -> Grafana UFW rule (replaced by VPN access) @@ -74,7 +74,7 @@ src: '{{ wireguard_network }}' proto: tcp state: enabled - comment: 'Prometheus — VPN access only' + comment: 'Prometheus - VPN access only' when: wireguard_prometheus_stat.stat.exists - name: Lockdown | Check if Munin is installed @@ -89,7 +89,7 @@ src: '{{ wireguard_network }}' proto: tcp state: enabled - comment: 'Munin — VPN access only' + comment: 'Munin - VPN access only' when: wireguard_munin_conf_stat.stat.exists - name: Lockdown | Remove proxy -> Munin UFW rule (replaced by VPN access) diff --git a/deploy/roles/wireguard/tasks/lockdown_postgres.yml b/deploy/roles/wireguard/tasks/lockdown_postgres.yml index acd069e..46f9d32 100644 --- a/deploy/roles/wireguard/tasks/lockdown_postgres.yml +++ b/deploy/roles/wireguard/tasks/lockdown_postgres.yml @@ -1,40 +1,72 @@ --- # Per-peer pg_hba.conf rules managed via a single blockinfile so that peer -# removal is idempotent — lineinfile cannot remove a previously-added line +# removal is idempotent: lineinfile cannot remove a previously-added line # when the entry disappears from inventory. -- name: Lockdown | Find pg_hba.conf - ansible.builtin.shell: | - set -o pipefail - find /etc/postgresql -name pg_hba.conf -type f | head -1 - args: - executable: /bin/bash - register: wireguard_pg_hba_path - changed_when: false +# Discover the active cluster directory off disk as root rather than via +# `become_user: postgres` + postgresql_info. On idmapped LXD mounts the +# become-to-unprivileged-user step cannot chown the temp file it transfers +# ("Unprivileged become user would be unable to read the file"), which aborts +# the lockdown run before lockdown_instances ever applies. Reading the cluster +# directory needs no unprivileged become and works on every connection type. +- name: Lockdown | Discover PostgreSQL cluster directories + ansible.builtin.find: + paths: /etc/postgresql + file_type: directory + depth: 1 + patterns: '[0-9]*' + use_regex: false + register: wireguard_pg_clusters +- name: Lockdown | Resolve pg_hba.conf path (highest installed major) + ansible.builtin.set_fact: + wireguard_pg_hba_path: >- + /etc/postgresql/{{ + wireguard_pg_clusters.files + | map(attribute='path') + | map('basename') + | map('int') + | max + }}/main/pg_hba.conf + when: wireguard_pg_clusters.files | length > 0 + +- name: Lockdown | Verify pg_hba.conf exists + ansible.builtin.stat: + path: "{{ wireguard_pg_hba_path | default('') }}" + register: wireguard_pg_hba_stat + when: wireguard_pg_hba_path is defined + +# database/user are derived from item.1.instance (the DHIS2 instance's LXD +# container name, which equals its db name, role and owner, see +# roles/create-instance/tasks/postgresql-db.yml). An explicit database/user +# on the entry overrides the derived value (e.g. { database: all, user: all }). - name: Lockdown | Build per-peer pg_hba lines ansible.builtin.set_fact: _wireguard_pg_hba_lines: >- {{ _wireguard_pg_hba_lines | default([]) + [ - 'hostssl ' ~ item.1.database - ~ ' ' ~ item.1.user + 'hostssl ' ~ (item.1.database | default(item.1.instance)) + ~ ' ' ~ (item.1.user | default(item.1.instance)) ~ ' ' ~ (item.0.peer_ip | default(item.0.allowed_ips.split(',')[0] | trim)) ~ ' scram-sha-256' ] }} loop: "{{ wireguard_peers | subelements('pg_access', skip_missing=True) }}" loop_control: - label: "{{ item.0.name }} -> {{ item.1.database }}/{{ item.1.user }}" + label: >- + {{ item.0.name }} -> + {{ item.1.database | default(item.1.instance) }}/{{ item.1.user | default(item.1.instance) }} - name: Lockdown | Manage per-peer pg_hba block ansible.builtin.blockinfile: - path: "{{ wireguard_pg_hba_path.stdout | trim }}" + path: "{{ wireguard_pg_hba_path }}" marker: "# {mark} ANSIBLE MANAGED — wireguard per-peer pg_access" block: "{{ _wireguard_pg_hba_lines | default([]) | join('\n') }}" state: "{{ 'present' if (_wireguard_pg_hba_lines | default([]) | length > 0) else 'absent' }}" insertbefore: EOF create: false notify: Reload PostgreSQL - when: wireguard_pg_hba_path.stdout | trim | length > 0 + when: + - wireguard_pg_hba_path is defined + - wireguard_pg_hba_stat.stat.exists | default(false) - name: Lockdown | Allow PostgreSQL (5432/tcp) from VPN subnet community.general.ufw: @@ -43,4 +75,4 @@ src: '{{ wireguard_network }}' proto: tcp state: enabled - comment: 'PostgreSQL — VPN access only' + comment: 'PostgreSQL - VPN access only' diff --git a/deploy/roles/wireguard/tasks/main.yml b/deploy/roles/wireguard/tasks/main.yml index f8e8f28..b428960 100644 --- a/deploy/roles/wireguard/tasks/main.yml +++ b/deploy/roles/wireguard/tasks/main.yml @@ -5,10 +5,10 @@ # (roles: [{ role: wireguard, vars: { wireguard_stage: ... } }]). # # Stages: -# host_portforward — provision hub LXD container + lxc network forward +# host_portforward - provision hub LXD container + lxc network forward # (LXD deployments only). -# hub — install/configure WireGuard server inside the hub. -# peer — install/configure WireGuard on each app host. +# hub - install/configure WireGuard server inside the hub. +# peer - install/configure WireGuard on each app host. # # Lockdown stages (lockdown_proxy, lockdown_monitor, lockdown_postgres, # lockdown_instances) are NOT exposed here; run diff --git a/deploy/roles/wireguard/tasks/validate.yml b/deploy/roles/wireguard/tasks/validate.yml index baeae97..61f82ba 100644 --- a/deploy/roles/wireguard/tasks/validate.yml +++ b/deploy/roles/wireguard/tasks/validate.yml @@ -40,7 +40,7 @@ == wireguard_peers | length fail_msg: >- Duplicate name detected in wireguard_peers. Names are used as - file paths for keys and configs — duplicates cause silent + file paths for keys and configs - duplicates cause silent overwrite. Values: {{ wireguard_peers | map(attribute='name') | list }} quiet: true when: wireguard_peers | length > 1 @@ -56,38 +56,55 @@ quiet: true when: wireguard_peers | length > 1 -- name: WireGuard | Assert pg_access entries are well-formed +# A pg_access entry sources its database/user EITHER from `instance` (the +# DHIS2 instance whose container name equals its db name and role - preferred, +# avoids hardcoding) OR from explicit `database`/`user` fields (for wildcards +# like { database: all, user: all }). An explicit field overrides the derived +# value when both are present. +- name: WireGuard | Assert pg_access entries reference an instance or set database/user ansible.builtin.assert: that: - - item.1.database is defined - - item.1.database is string - - item.1.database | length > 0 - - item.1.user is defined - - item.1.user is string - - item.1.user | length > 0 + - (item.1.instance is defined) or (item.1.database is defined and item.1.user is defined) fail_msg: >- - wireguard_peers[{{ item.0.name }}].pg_access entry must have non-empty - 'database' and 'user' string fields. + wireguard_peers[{{ item.0.name }}].pg_access entry must set either + 'instance' (a host in [instances]) or both 'database' and 'user'. quiet: true loop: "{{ wireguard_peers | subelements('pg_access', skip_missing=True) }}" loop_control: - label: "{{ item.0.name }} -> {{ item.1.database | default('?') }}/{{ item.1.user | default('?') }}" + label: "{{ item.0.name }}" + +- name: WireGuard | Assert pg_access instance references exist in [instances] + ansible.builtin.assert: + that: + - item.1.instance in groups.get('instances', []) + fail_msg: >- + wireguard_peers[{{ item.0.name }}].pg_access references + instance='{{ item.1.instance }}' which is not a host in the [instances] + inventory group. Available: {{ groups.get('instances', []) }}. + quiet: true + loop: "{{ wireguard_peers | subelements('pg_access', skip_missing=True) }}" + loop_control: + label: "{{ item.0.name }} -> {{ item.1.instance | default('(explicit)') }}" + when: item.1.instance is defined - name: WireGuard | Assert pg_access database/user names are PostgreSQL-safe identifiers ansible.builtin.assert: that: - - item.1.database is match('^[a-zA-Z0-9_]+$') - - item.1.user is match('^[a-zA-Z0-9_]+$') + - (item.1.database | default(item.1.instance)) is match('^[a-zA-Z0-9_]+$') + - (item.1.user | default(item.1.instance)) is match('^[a-zA-Z0-9_]+$') fail_msg: >- - wireguard_peers[{{ item.0.name }}].pg_access entry - database='{{ item.1.database }}' user='{{ item.1.user }}' contains + wireguard_peers[{{ item.0.name }}].pg_access entry resolves to + database='{{ item.1.database | default(item.1.instance) }}' + user='{{ item.1.user | default(item.1.instance) }}' which contains invalid characters. Allowed: letters, digits, underscore. Required because these values are interpolated into pg_hba.conf rule regex - matching — metacharacters would corrupt idempotency. + matching - metacharacters would corrupt idempotency. quiet: true loop: "{{ wireguard_peers | subelements('pg_access', skip_missing=True) }}" loop_control: - label: "{{ item.0.name }} -> {{ item.1.database }}/{{ item.1.user }}" + label: >- + {{ item.0.name }} -> + {{ item.1.database | default(item.1.instance) }}/{{ item.1.user | default(item.1.instance) }} - name: WireGuard | Assert peer_ip (or first allowed_ips CIDR) is /32 for peers with pg_access ansible.builtin.assert: @@ -98,7 +115,7 @@ fail_msg: >- wireguard_peers[{{ item.name }}] uses pg_access but its peer_ip (or first allowed_ips CIDR) does not end in /32. Per-peer pg_hba/UFW - rules treat the CIDR as a single host — non-/32 entries would silently + rules treat the CIDR as a single host - non-/32 entries would silently open access wider than intended. Set peer_ip explicitly or ensure the first allowed_ips CIDR is /32. Got: peer_ip={{ item.peer_ip | default('(unset)') }} diff --git a/deploy/roles/wireguard/templates/client.conf.j2 b/deploy/roles/wireguard/templates/client.conf.j2 index 8a7fca3..7ebb112 100644 --- a/deploy/roles/wireguard/templates/client.conf.j2 +++ b/deploy/roles/wireguard/templates/client.conf.j2 @@ -1,5 +1,5 @@ # WireGuard peer config: {{ item.name }} ({{ item.endpoint_kind }}) -# Generated by Ansible — do not commit to version control +# Generated by Ansible. Do not commit to version control. [Interface] Address = {{ item.wireguard_ip }}/32 diff --git a/deploy/roles/wireguard/templates/wg0.conf.j2 b/deploy/roles/wireguard/templates/wg0.conf.j2 index f80ab94..cc69ea9 100644 --- a/deploy/roles/wireguard/templates/wg0.conf.j2 +++ b/deploy/roles/wireguard/templates/wg0.conf.j2 @@ -1,7 +1,7 @@ {{ ansible_managed | comment }} -# WireGuard hub config — runs inside the {{ wireguard_hub_inventory_hostname }} container. -# Spoke ↔ spoke routing happens via kernel forwarding on this hub -# (net.ipv4.ip_forward=1 + iptables FORWARD wg0↔wg0 ACCEPT). +# WireGuard hub config - runs inside the {{ wireguard_hub_inventory_hostname }} container. +# Spoke-to-spoke routing happens via kernel forwarding on this hub +# (net.ipv4.ip_forward=1 + iptables FORWARD wg0-to-wg0 ACCEPT). [Interface] Address = {{ wireguard_server_ip }}/24 diff --git a/docs/WireGuard-VPN.md b/docs/WireGuard-VPN.md index f13f48d..faa2abd 100644 --- a/docs/WireGuard-VPN.md +++ b/docs/WireGuard-VPN.md @@ -4,12 +4,12 @@ WireGuard provides a secure VPN tunnel for administering DHIS2 infrastructure. T WireGuard is set up by `dhis2.yml` in a single deploy, in two stages run back-to-back: -1. **Mesh bring-up** — `playbooks/wireguard.yml`. Creates the hub container (LXD only), installs WireGuard everywhere, and connects every peer. -2. **Service lockdown** — `playbooks/wireguard-lockdown.yml`. Restricts Grafana, Prometheus, Munin, Glowroot and PostgreSQL to the VPN subnet only. +1. **Mesh bring-up** - `playbooks/wireguard.yml`. Creates the hub container (LXD only), installs WireGuard everywhere, and connects every peer. +2. **Service lockdown** - `playbooks/wireguard-lockdown.yml`. Restricts Grafana, Prometheus, Munin, Glowroot and PostgreSQL to the VPN subnet only. Both are imported by `dhis2.yml` and gated on `wireguard_enabled`, so a single `sudo ./deploy.sh` (or `ansible-playbook dhis2.yml`) takes you all the way to a hardened deployment. SSH on port 22 and the DHIS2 web app on 80/443 are deliberately left public. -If you need the mesh without the firewall (e.g. during cut-over, while not all admins are on the VPN yet), skip lockdown with `--skip-tags wireguard-lockdown` — see [Skipping or reverting the lockdown](#skipping-or-reverting-the-lockdown). +If you need the mesh without the firewall (e.g. during cut-over, while not all admins are on the VPN yet), skip lockdown with `--skip-tags wireguard-lockdown` - see [Skipping or reverting the lockdown](#skipping-or-reverting-the-lockdown). ## Architecture @@ -43,11 +43,11 @@ If you need the mesh without the firewall (e.g. during cut-over, while not all a (Numbers under each box: `/`.) -**Topology**: hub-and-spoke. Each spoke (app container, home machine) has a single `[Peer]` pointing at the hub with `AllowedIPs = 10.0.0.0/24` and `PersistentKeepalive = 25`. Spoke ↔ spoke traffic relays through the hub (which has `net.ipv4.ip_forward=1` and `iptables -A FORWARD -i wg0 -o wg0 -j ACCEPT`). +**Topology**: hub-and-spoke. Each spoke (app container, home machine) has a single `[Peer]` pointing at the hub with `AllowedIPs = 10.0.0.0/24` and `PersistentKeepalive = 25`. Spoke-to-spoke traffic relays through the hub (which has `net.ipv4.ip_forward=1` and `iptables -A FORWARD -i wg0 -o wg0 -j ACCEPT`). **Endpoint resolution**: -- App container peers: `Endpoint = 172.19.2.200:51820` — resolved internally over `lxdbr1`. -- Home peers: `Endpoint = :51820` — resolved over the internet, NAT'd by the LXD host through `lxc network forward`. +- App container peers: `Endpoint = 172.19.2.200:51820` - resolved internally over `lxdbr1`. +- Home peers: `Endpoint = :51820` - resolved over the internet, NAT'd by the LXD host through `lxc network forward`. Two separate vars control this: @@ -56,19 +56,19 @@ Two separate vars control this: | `wireguard_endpoint_listen` | `lxc network forward` listen address on the host | auto-detect (`ansible_default_ipv4.address`) | | `wireguard_endpoint_public` | `Endpoint =` line written into home-peer `.conf` files | falls back to `wireguard_endpoint_listen` | -On a host with a single primary public IP, leaving both empty works. On cloud VMs with 1:1 NAT (AWS EIP, GCP external IP, Azure public IP), the host's primary interface holds a *private* IP — auto-detect picks the private IP. The forward must still bind on that private IP (it is the only IP the host owns), but home peers must dial the public IP. In that case set: +On a host with a single primary public IP, leaving both empty works. On cloud VMs with 1:1 NAT (AWS EIP, GCP external IP, Azure public IP), the host's primary interface holds a *private* IP - auto-detect picks the private IP. The forward must still bind on that private IP (it is the only IP the host owns), but home peers must dial the public IP. In that case set: ```ini # in inventory/hosts [all:vars] wireguard_endpoint_public=203.0.113.42 # public IP or DNS name -# wireguard_endpoint_listen left empty — auto-detect picks the private primary +# wireguard_endpoint_listen left empty - auto-detect picks the private primary ``` -**App-to-app traffic** (e.g. dhis → postgres) continues to use the LXD bridge (`172.19.2.x`); only admin/external traffic is routed through WG. UFW lockdown rules continue to allow `src=10.0.0.0/24`; packets arrive on each container's `wg0` with the peer's WG IP as source. +**App-to-app traffic** (e.g. dhis to postgres) continues to use the LXD bridge (`172.19.2.x`); only admin/external traffic is routed through WG. UFW lockdown rules continue to allow `src=10.0.0.0/24`; packets arrive on each container's `wg0` with the peer's WG IP as source. ## Prerequisites -- Ubuntu 22.04+ (kernel ≥ 5.6 includes the WireGuard kernel module) +- Ubuntu 22.04+ (kernel 5.6 or newer includes the WireGuard kernel module) - UFW firewall enabled on the host - A working dhis2-server-tools LXD deployment (or ready to deploy) - WireGuard client installed on your admin workstation @@ -113,14 +113,14 @@ wireguard ansible_host=172.19.2.200 wireguard_ip=10.0.0.1 ### 2. Define human peers -Edit `deploy/inventory/group_vars/all/vars.yml`. Assign IPs from `10.0.0.6` upward (`.2`–`.5` are reserved for app containers in the default inventory): +Edit `deploy/inventory/group_vars/all/vars.yml`. Assign IPs from `10.0.0.6` upward (`.2`-`.5` are reserved for app containers in the default inventory): ```yaml wireguard_peers: - name: sysadmin allowed_ips: "10.0.0.6/32" pg_access: - - { database: dhis, user: dhis } + - { instance: dhis } - name: admin-bob allowed_ips: "10.0.0.7/32" @@ -148,7 +148,7 @@ sudo ansible-playbook dhis2.yml --skip-tags wireguard-lockdown A `wireguard_enabled=true` run of `dhis2.yml` will: -- Provision the `wireguard` LXD container at `172.19.2.200` (LXD setups only — skipped automatically on SSH/distributed deployments). +- Provision the `wireguard` LXD container at `172.19.2.200` (LXD setups only - skipped automatically on SSH/distributed deployments). - Add an `lxc network forward` rule so UDP `51820` from the host's public IP lands inside the wireguard container (LXD only). - Install WireGuard packages inside the hub and inside every app container. - Generate hub + peer keypairs (preserved across runs). @@ -169,7 +169,7 @@ sudo lxc file pull wireguard/etc/wireguard/clients/sysadmin.conf . scp sysadmin.conf my-laptop:~/ ``` -The config is complete — no editing needed. Import directly into your WireGuard client. +The config is complete - no editing needed. Import directly into your WireGuard client. **Connect:** @@ -181,7 +181,7 @@ sudo wg-quick up /path/to/sysadmin.conf # Import the .conf file into the WireGuard app # Mobile (generate QR code on the hub). -# qrencode is not pulled in by wireguard-tools — install it inside the hub +# qrencode is not pulled in by wireguard-tools - install it inside the hub # container first. The redirection must run inside the container, hence # `bash -c '...'`; a top-level `<` would be parsed by the LXD host shell # and fail because the .conf file lives inside the container. @@ -210,24 +210,24 @@ ping 10.0.0.5 # monitor via mesh relay **Lockdown effects (skip this block if you ran with `--skip-tags wireguard-lockdown`):** ```bash -# From the LXD host (not on the VPN) — should fail / time out. -curl -m 3 http://172.19.2.30:3000/ # Grafana — was reachable, now blocked -curl -m 3 http://172.19.2.11:4000/ # Glowroot — was reachable, now blocked +# From the LXD host (not on the VPN) - should fail / time out. +curl -m 3 http://172.19.2.30:3000/ # Grafana - was reachable, now blocked +curl -m 3 http://172.19.2.11:4000/ # Glowroot - was reachable, now blocked # From a connected WG peer (e.g. 10.0.0.6): curl -m 3 http://10.0.0.5:3000/ # Grafana via VPN curl -m 3 http://10.0.0.4:4000/ # Glowroot via VPN psql -h 10.0.0.3 -U dhis -d dhis2 # only if pg_access is set for this peer -# DHIS2 itself stays public — sanity check it hasn't moved: +# DHIS2 itself stays public - sanity check it hasn't moved: curl -I https://your.dhis2.fqdn/ # expect 200 / 302 ``` -If the lockdown checks fail but the mesh checks pass, the most likely cause is a misconfigured `wireguard_endpoint_public` (cloud 1:1 NAT) or UDP `51820` blocked at the cloud security group — see [Troubleshooting](#troubleshooting). To recover monitoring access while you debug, run `sudo ansible-playbook dhis2.yml --skip-tags wireguard-lockdown` to peel lockdown off without tearing down the mesh. +If the lockdown checks fail but the mesh checks pass, the most likely cause is a misconfigured `wireguard_endpoint_public` (cloud 1:1 NAT) or UDP `51820` blocked at the cloud security group - see [Troubleshooting](#troubleshooting). To recover monitoring access while you debug, run `sudo ansible-playbook dhis2.yml --skip-tags wireguard-lockdown` to peel lockdown off without tearing down the mesh. ## Service lockdown -The lockdown stage runs automatically as part of `dhis2.yml` whenever `wireguard_enabled=true`. It is idempotent — re-running with no inventory changes does nothing — and can also be invoked standalone to re-apply after manual UFW edits: +The lockdown stage runs automatically as part of `dhis2.yml` whenever `wireguard_enabled=true`. It is idempotent - re-running with no inventory changes does nothing - and can also be invoked standalone to re-apply after manual UFW edits: ```bash cd deploy/ @@ -259,7 +259,7 @@ Each lockdown step has its own tag so you can opt into or out of a subset. Tags | Tag | Effect | |---|---| | `lockdown-proxy` | Empties monitoring upstream configs on nginx/apache; re-renders DHIS2 vhosts without `/glowroot` blocks | -| `lockdown-monitor` | UFW rules: allow Grafana/Prometheus/Munin from VPN subnet only; remove proxy → Grafana and proxy → Munin rules | +| `lockdown-monitor` | UFW rules: allow Grafana/Prometheus/Munin from VPN subnet only; remove proxy-to-Grafana and proxy-to-Munin rules | | `lockdown-postgres` | Per-peer `pg_hba.conf` rules from `wireguard_peers[*].pg_access`; UFW rule allowing 5432 from VPN subnet | | `lockdown-instances` | UFW rule allowing Glowroot 4000 from VPN subnet; munin-node 4949 restricted to monitor container | | `wireguard-lockdown` | Umbrella tag matching all four of the above (used by `--skip-tags wireguard-lockdown`) | @@ -275,16 +275,16 @@ sudo ansible-playbook playbooks/wireguard-lockdown.yml --tags lockdown-proxy ### PostgreSQL VPN access -Database access is granted **per peer** via the optional `pg_access` field. A peer without `pg_access` has **no** PostgreSQL access — the role does not add a blanket grant. +Database access is granted **per peer** via the optional `pg_access` field. A peer without `pg_access` has **no** PostgreSQL access - the role does not add a blanket grant. -Each `pg_access` entry is a `{database, user}` pair. The role writes one `hostssl scram-sha-256` line to `pg_hba.conf` per entry. A password is still required. +Each `pg_access` entry is either `{ instance: }` - which derives both the database and the role from a `[instances]` host's LXD container name (in this project a DHIS2 instance's database, role and owner all equal its container name) - or an explicit `{ database, user }` pair for wildcards. The role writes one `hostssl scram-sha-256` line to `pg_hba.conf` per entry. A password is still required. ```yaml wireguard_peers: - name: sysadmin allowed_ips: "10.0.0.6/32" pg_access: - - { database: dhis, user: dhis } # least-privilege + - { instance: dhis } # least-privilege; tracks the 'dhis' instance container # - name: superuser # allowed_ips: "10.0.0.7/32" @@ -292,37 +292,37 @@ wireguard_peers: # - { database: all, user: all } # superuser-equivalent ``` -`database` and `user` must match `^[a-zA-Z0-9_]+$`. The PostgreSQL keyword `all` is allowed; arbitrary identifiers with regex metacharacters are rejected at validation time. +A referenced `instance` must be a host in the `[instances]` group. Resolved `database`/`user` names must match `^[a-zA-Z0-9_]+$`. The PostgreSQL keyword `all` is allowed (via the explicit `{ database, user }` form); arbitrary identifiers with regex metacharacters are rejected at validation time. If a peer's `allowed_ips` routes additional networks (comma-separated CIDRs), set `peer_ip` explicitly to the single `/32` used for pg_hba/UFW rules. -App-level pg_hba entries (added by the `create-instance` role) are unaffected — they continue to work over the LXD bridge. +App-level pg_hba entries (added by the `create-instance` role) are unaffected - they continue to work over the LXD bridge. The role manages all `pg_access`-derived rules inside a single `blockinfile` block delimited by `# BEGIN/END ANSIBLE MANAGED — wireguard per-peer pg_access`. Removing a peer (or its `pg_access` entry) and re-running `playbooks/wireguard-lockdown.yml` removes the corresponding `hostssl` line. ### Skipping or reverting the lockdown -Because lockdown is now part of `dhis2.yml`, "skipping" and "reverting" are the same operation: tell `dhis2.yml` not to run the lockdown tag(s) you want to undo. The mesh is unaffected — only the firewall and proxy hardening flips back. +Because lockdown is now part of `dhis2.yml`, "skipping" and "reverting" are the same operation: tell `dhis2.yml` not to run the lockdown tag(s) you want to undo. The mesh is unaffected - only the firewall and proxy hardening flips back. ```bash # Skip the whole lockdown for this run (mesh stays up, services revert # to public). Idempotent: re-runs without the flag will re-lock them. sudo ansible-playbook dhis2.yml --skip-tags wireguard-lockdown -# Revert one component only — e.g. unlock PostgreSQL while a remote DBA +# Revert one component only - e.g. unlock PostgreSQL while a remote DBA # joins the VPN, then drop the flag once they're on: sudo ansible-playbook dhis2.yml --skip-tags lockdown-postgres ``` -Important: skipping a lockdown tag on its own does **not** restore the original UFW rules / nginx vhost content — it just stops the lockdown tasks from running on that play. To re-create the pre-lockdown state, also re-run the role that originally produced those rules: +Important: skipping a lockdown tag on its own does **not** restore the original UFW rules / nginx vhost content - it just stops the lockdown tasks from running on that play. To re-create the pre-lockdown state, also re-run the role that originally produced those rules: ```bash -# Restore proxy → monitoring UFW rules and the /glowroot proxy block: +# Restore proxy-to-monitoring UFW rules and the /glowroot proxy block: sudo ansible-playbook dhis2.yml --tags monitoring,proxy-install \ --skip-tags wireguard-lockdown ``` -To turn WireGuard off completely (mesh + lockdown), set `wireguard_enabled=false` in inventory and re-run `dhis2.yml`. The mesh plays no-op and the lockdown plays no-op — but again, services that were already locked down won't auto-revert; run the `dhis2.yml --tags monitoring,proxy-install --skip-tags wireguard-lockdown` recipe above to restore them. +To turn WireGuard off completely (mesh + lockdown), set `wireguard_enabled=false` in inventory and re-run `dhis2.yml`. The mesh plays no-op and the lockdown plays no-op - but again, services that were already locked down won't auto-revert; run the `dhis2.yml --tags monitoring,proxy-install --skip-tags wireguard-lockdown` recipe above to restore them. ## Configuration reference @@ -353,18 +353,18 @@ App containers are auto-derived from inventory `wireguard_ip` and must NOT be li | Field | Required | Description | |---|---|---| -| `name` | Yes | Identifier — filesystem-safe (letters, digits, dot, underscore, hyphen) | +| `name` | Yes | Identifier - filesystem-safe (letters, digits, dot, underscore, hyphen) | | `allowed_ips` | Yes | Peer's VPN IP (e.g. `10.0.0.6/32`). May be comma-separated to route additional networks | | `public_key` | No* | Peer's WG public key. *Required only when `wireguard_auto_generate_keys: false` | | `preshared_key` | No | Optional PSK for post-quantum hedge | | `peer_ip` | No | Single `/32` CIDR for pg_hba/UFW rules. Defaults to first CIDR in `allowed_ips` | -| `pg_access` | No | List of `{database, user}` — adds per-peer pg_hba rules | +| `pg_access` | No | List of `{ instance: }` (derives db/user from the instance container) or `{ database, user }` - adds per-peer pg_hba rules | ### Key generation modes **Auto-generate (default)**: `wireguard_auto_generate_keys: true` -Only `name` and `allowed_ips` required per human peer. The hub container generates every keypair — including its own and one per app container — and produces complete, ready-to-import `.conf` files. +Only `name` and `allowed_ips` required per human peer. The hub container generates every keypair - including its own and one per app container - and produces complete, ready-to-import `.conf` files. **Manual keys**: `wireguard_auto_generate_keys: false` @@ -442,7 +442,7 @@ The affected peer must re-import their updated `.conf`. ## Migration from 10.8.0.0/24 (host-bridge architecture) -Earlier versions of this role ran WireGuard on the LXD host with a `wg0 ↔ lxdbr1` bridge and used the `10.8.0.0/24` subnet. To migrate: +Earlier versions of this role ran WireGuard on the LXD host with a `wg0`-to-`lxdbr1` bridge and used the `10.8.0.0/24` subnet. To migrate: ```bash # 1. On the LXD host: tear down the old WG instance. @@ -462,11 +462,11 @@ cd deploy/ sudo ./deploy.sh ``` -Old `10.8.0.0/24` client `.conf` files will not work — re-import the freshly generated `10.0.0.0/24` configs. +Old `10.8.0.0/24` client `.conf` files will not work - re-import the freshly generated `10.0.0.0/24` configs. ## Disabling WireGuard -Set `wireguard_enabled=false` in the inventory. Subsequent `dhis2.yml` runs no-op both the mesh and the lockdown — every play in `playbooks/wireguard.yml` and `playbooks/wireguard-lockdown.yml` gates on `wireguard_enabled`. +Set `wireguard_enabled=false` in the inventory. Subsequent `dhis2.yml` runs no-op both the mesh and the lockdown - every play in `playbooks/wireguard.yml` and `playbooks/wireguard-lockdown.yml` gates on `wireguard_enabled`. This **stops future WireGuard changes** but does **not** tear down an existing hub container or revert UFW / `pg_hba.conf` / proxy edits already applied by previous lockdown runs. To fully remove: