Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions deploy/ansible.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion deploy/dhis2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
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:
Expand Down Expand Up @@ -44,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

Check failure on line 61 in deploy/dhis2.yml

View workflow job for this annotation

GitHub Actions / Lint

name[play]

All plays should be named.
tags: [never, delete-instance]
2 changes: 0 additions & 2 deletions deploy/inventory/group_vars/all.template

This file was deleted.

29 changes: 29 additions & 0 deletions deploy/inventory/group_vars/all/vars.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
# 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).

#
# 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: <hostname> } 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: "<peer-public-key>" # only needed if wireguard_auto_generate_keys: false
# preshared_key: "<peer-preshared-key>" # optional - for post-quantum resistance
pg_access:
- { instance: dhis } # access the 'dhis' instance's database as its own role
# - 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
18 changes: 14 additions & 4 deletions deploy/inventory/hosts.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,6 +55,11 @@ postgresql_version=16
server_monitoring=munin
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.
wireguard_enabled=false


# lxd
lxd_network=172.19.2.1/24
Expand Down
102 changes: 102 additions & 0 deletions deploy/playbooks/wireguard-lockdown.yml
Original file line number Diff line number Diff line change
@@ -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
122 changes: 122 additions & 0 deletions deploy/playbooks/wireguard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
---
# 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. 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[wireguard_hub_inventory_hostname | default('wireguard')]['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:
# 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
tasks_from: peer.yml
when:
- wireguard_enabled | default(false) | bool
- hostvars[inventory_hostname].wireguard_ip is defined
- hostvars[inventory_hostname].wireguard_ip | length > 0
6 changes: 4 additions & 2 deletions deploy/roles/create-instance/templates/apache2/instance.j2
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
{% 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(false) | bool %}
{% if hostvars[item]['dhis2_base_path'] | default(item) | to_fixed_string == "ROOT" %}
<Location />
Require all granted
ProxyPass "http://{{ hostvars[item]['ansible_host']+':8080' }}/"
ProxyPassReverse "http://{{ hostvars[item]['ansible_host']+':8080'}}/"
</Location>

{% if app_monitoring is defined and app_monitoring | trim == 'glowroot' %}
{% if _glowroot_enabled and not _glowroot_locked %}
<Location /glowroot>
Require all granted
ProxyPass "http://{{ hostvars[item]['ansible_host']+':4000' }}/glowroot"
Expand All @@ -19,7 +21,7 @@
ProxyPassReverse "http://{{ hostvars[item]['ansible_host']+':8080'}}/{{ hostvars[item]['dhis2_base_path'] | default(item) | to_fixed_string }}"
</Location>

{% if app_monitoring is defined and app_monitoring | trim == 'glowroot' %}
{% if _glowroot_enabled and not _glowroot_locked %}
<Location /{{ hostvars[item]['dhis2_base_path'] | default(item) | to_fixed_string }}-glowroot>
Require all granted
ProxyPass "http://{{ hostvars[item]['ansible_host']+':4000' }}/{{ hostvars[item]['dhis2_base_path'] | default(item) | to_fixed_string }}-glowroot"
Expand Down
Loading
Loading