201 lines
7.9 KiB
YAML
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"
|