201 lines
7.9 KiB
YAML

---
# playbooks/onboarding/watchtower_audit.yml
# Read-only audit for the Ansible control node (Watchtower).
# Safe to schedule. Makes no changes to any host.
#
# What this asserts:
# - Kernel / distro / swap / swappiness / bridge netfilter / ip_forward
# - Docker daemon log rotation configured
# - Python venv exists
# - Ansible version meets minimum (>= 2.18.0)
# - SSH private key present
#
# Usage:
# ansible-playbook -i inventory/hosts.ini playbooks/onboarding/watchtower_audit.yml
#
# Output:
# outputs/watchtower_audit_<timestamp>.md (repo root)
- name: "Play 1: Gather Watchtower state"
hosts: watchtower
become: true
gather_facts: true
vars:
lab_user: "{{ lab_ansible_user | default('chester') }}"
homelab_root: "/home/{{ lab_user }}/homelab"
venv_path: "{{ homelab_root }}/.venv"
ssh_key_path: "/home/{{ lab_user }}/.ssh/id_ed25519"
min_ansible_version: "2.18.0"
tasks:
- name: Read sysctl values
ansible.builtin.shell: "sysctl -n {{ item }} 2>/dev/null || echo 0"
register: sysctl_raw
loop:
- vm.swappiness
- net.bridge.bridge-nf-call-iptables
- net.bridge.bridge-nf-call-ip6tables
- net.ipv4.ip_forward
changed_when: false
check_mode: false
- name: Read Docker daemon.json
ansible.builtin.command: cat /etc/docker/daemon.json
register: daemon_json_content
changed_when: false
failed_when: false
check_mode: false
- name: Check Python venv exists
ansible.builtin.stat:
path: "{{ venv_path }}/bin/activate"
register: venv_stat
- name: Get Ansible version from venv
ansible.builtin.command: "{{ venv_path }}/bin/ansible --version"
register: ansible_version_raw
changed_when: false
failed_when: false
check_mode: false
become: false
- name: Check SSH private key exists
ansible.builtin.stat:
path: "{{ ssh_key_path }}"
register: ssh_key_stat
become: false
- name: Stash audit facts
ansible.builtin.set_fact:
wt_audit:
kernel: "{{ ansible_kernel }}"
arch: "{{ ansible_architecture }}"
distro: "{{ ansible_distribution }}"
distro_version: "{{ ansible_distribution_version }}"
swap_mb: "{{ ansible_swaptotal_mb }}"
swappiness: "{{ (sysctl_raw.results | selectattr('item', 'equalto', 'vm.swappiness') | first).stdout | trim }}"
bridge_iptables: "{{ (sysctl_raw.results | selectattr('item', 'equalto', 'net.bridge.bridge-nf-call-iptables') | first).stdout | trim }}"
bridge_ip6tables: "{{ (sysctl_raw.results | selectattr('item', 'equalto', 'net.bridge.bridge-nf-call-ip6tables') | first).stdout | trim }}"
ip_forward: "{{ (sysctl_raw.results | selectattr('item', 'equalto', 'net.ipv4.ip_forward') | first).stdout | trim }}"
daemon_json: "{{ daemon_json_content.stdout | default('{}') }}"
log_rotation_configured: "{{ 'max-size' in (daemon_json_content.stdout | default('{}')) }}"
venv_exists: "{{ venv_stat.stat.exists }}"
ansible_version_raw: "{{ ansible_version_raw.stdout_lines[0] | default('unknown') }}"
ansible_version_number: "{{ ansible_version_raw.stdout | regex_search('ansible \\[core ([0-9.]+)\\]', '\\1') | first | default('0.0.0') }}"
ansible_version_ok: "{{ (ansible_version_raw.stdout | regex_search('ansible \\[core ([0-9.]+)\\]', '\\1') | first | default('0.0.0')) is version(min_ansible_version, '>=') }}"
ssh_key_present: "{{ ssh_key_stat.stat.exists }}"
- name: "Play 2: Assertions and drift report"
hosts: localhost
gather_facts: false
vars:
audit_timestamp: "{{ lookup('pipe', 'date +%Y%m%dT%H%M%S') }}"
report_path: "{{ playbook_dir }}/../../../outputs/watchtower_audit_{{ audit_timestamp }}.md"
wt: "{{ hostvars['localhost']['wt_audit'] }}"
tasks:
- name: Ensure outputs directory exists
ansible.builtin.file:
path: "{{ playbook_dir }}/../../../outputs"
state: directory
mode: '0755'
- name: Write drift report
ansible.builtin.copy:
dest: "{{ report_path }}"
mode: '0644'
content: |
# Watchtower Control Node Audit Report
Generated: {{ audit_timestamp }}
## System
| Property | Value |
|----------|-------|
| Kernel | `{{ wt.kernel }}` |
| Architecture | {{ wt.arch }} |
| Distro | {{ wt.distro }} {{ wt.distro_version }} |
| Swap | {{ wt.swap_mb }}MB |
## Sysctl
| Parameter | Value | Expected |
|-----------|-------|----------|
| vm.swappiness | {{ wt.swappiness }} | 0 |
| net.bridge.bridge-nf-call-iptables | {{ wt.bridge_iptables }} | 1 |
| net.bridge.bridge-nf-call-ip6tables | {{ wt.bridge_ip6tables }} | 1 |
| net.ipv4.ip_forward | {{ wt.ip_forward }} | 1 |
## Toolchain
| Check | Status |
|-------|--------|
| Python venv | {{ '✅ exists' if wt.venv_exists | bool else '❌ missing' }} |
| Ansible version | {{ wt.ansible_version_raw }} |
| Ansible version ok (>= 2.18.0) | {{ '✅' if wt.ansible_version_ok | bool else '❌' }} |
| SSH private key | {{ '✅ present' if wt.ssh_key_present | bool else '❌ missing' }} |
## Docker
| Check | Status |
|-------|--------|
| Log rotation configured | {{ '✅' if wt.log_rotation_configured | bool else '❌' }} |
- name: Assert swap is disabled
ansible.builtin.assert:
that: wt.swap_mb | int == 0
fail_msg: "❌ Swap enabled: {{ wt.swap_mb }}MB — run watchtower_baseline.yml --tags storage"
success_msg: "✅ Watchtower: swap disabled"
- name: Assert vm.swappiness=0
ansible.builtin.assert:
that: wt.swappiness | int == 0
fail_msg: "❌ vm.swappiness={{ wt.swappiness }} — run watchtower_baseline.yml --tags sysctl"
success_msg: "✅ Watchtower: vm.swappiness=0"
- name: Assert bridge netfilter enabled
ansible.builtin.assert:
that:
- wt.bridge_iptables | int == 1
- wt.bridge_ip6tables | int == 1
fail_msg: >-
❌ Bridge netfilter not fully enabled:
bridge-nf-call-iptables={{ wt.bridge_iptables }}
bridge-nf-call-ip6tables={{ wt.bridge_ip6tables }}
Run watchtower_baseline.yml --tags sysctl.
success_msg: "✅ Watchtower: bridge netfilter enabled"
- name: Assert ip_forward enabled
ansible.builtin.assert:
that: wt.ip_forward | int == 1
fail_msg: "❌ net.ipv4.ip_forward={{ wt.ip_forward }} — run watchtower_baseline.yml --tags sysctl"
success_msg: "✅ Watchtower: ip_forward=1"
- name: Assert Docker log rotation configured
ansible.builtin.assert:
that: wt.log_rotation_configured | bool
fail_msg: "❌ Docker log rotation not configured — run watchtower_baseline.yml --tags docker"
success_msg: "✅ Watchtower: Docker log rotation configured"
- name: Assert Python venv exists
ansible.builtin.assert:
that: wt.venv_exists | bool
fail_msg: "❌ Python venv missing — run watchtower_baseline.yml --tags toolchain"
success_msg: "✅ Watchtower: Python venv present"
- name: Assert Ansible version meets minimum
ansible.builtin.assert:
that: wt.ansible_version_ok | bool
fail_msg: "❌ Ansible version {{ wt.ansible_version_number }} is below minimum 2.18.0 — run watchtower_baseline.yml --tags toolchain"
success_msg: "✅ Watchtower: Ansible {{ wt.ansible_version_number }} >= 2.18.0"
- name: Assert SSH private key present
ansible.builtin.assert:
that: wt.ssh_key_present | bool
fail_msg: "❌ SSH private key missing — generate with: ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519"
success_msg: "✅ Watchtower: SSH private key present"