--- # 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_.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"