--- # playbooks/docker/heimdall_audit.yml # Read-only OS and stack health audit for the Heimdall edge router. # Safe to schedule. Makes no changes to any host. # # What this asserts: # OS: kernel, distro, swap, swappiness, bridge netfilter, ip_forward # Docker: log rotation configured # Stack: traefik, redis, docker-socket-proxy containers are running # # Usage: # ansible-playbook -i inventory/hosts.ini playbooks/docker/heimdall_audit.yml # # Output: # outputs/heimdall_audit_.md (repo root) - name: "Play 1: Gather Heimdall state" hosts: heimdall become: true gather_facts: true 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: Get running container names ansible.builtin.command: > docker ps --format '{{ '{{' }}.Names{{ '}}' }}' register: running_containers changed_when: false failed_when: false check_mode: false - name: Stash audit facts ansible.builtin.set_fact: heimdall_audit: kernel: "{{ ansible_kernel }}" 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 }}" log_rotation_configured: "{{ 'max-size' in (daemon_json_content.stdout | default('{}')) }}" running_containers: "{{ running_containers.stdout_lines | default([]) }}" traefik_running: "{{ running_containers.stdout_lines | default([]) | select('search', 'traefik') | list | length > 0 }}" redis_running: "{{ running_containers.stdout_lines | default([]) | select('search', 'redis') | list | length > 0 }}" socket_proxy_running: "{{ running_containers.stdout_lines | default([]) | select('search', 'socket-proxy|socketproxy|docker-socket') | list | length > 0 }}" - 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/heimdall_audit_{{ audit_timestamp }}.md" h: "{{ hostvars['heimdall']['heimdall_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: | # Heimdall Edge Router Audit Report Generated: {{ audit_timestamp }} ## System | Property | Value | |----------|-------| | Kernel | `{{ h.kernel }}` | | Distro | {{ h.distro }} {{ h.distro_version }} | | Swap | {{ h.swap_mb }}MB | ## Sysctl | Parameter | Value | Expected | |-----------|-------|----------| | vm.swappiness | {{ h.swappiness }} | 0 | | net.bridge.bridge-nf-call-iptables | {{ h.bridge_iptables }} | 1 | | net.bridge.bridge-nf-call-ip6tables | {{ h.bridge_ip6tables }} | 1 | | net.ipv4.ip_forward | {{ h.ip_forward }} | 1 | ## Docker | Check | Status | |-------|--------| | Log rotation configured | {{ '✅' if h.log_rotation_configured | bool else '❌' }} | ## Stack Health | Container | Status | |-----------|--------| | traefik | {{ '✅ running' if h.traefik_running | bool else '❌ not running' }} | | redis | {{ '✅ running' if h.redis_running | bool else '❌ not running' }} | | docker-socket-proxy | {{ '✅ running' if h.socket_proxy_running | bool else '❌ not running' }} | ## Running Containers {% for c in h.running_containers %} - {{ c }} {% endfor %} - name: Assert swap is disabled ansible.builtin.assert: that: h.swap_mb | int == 0 fail_msg: "❌ Swap enabled: {{ h.swap_mb }}MB — run heimdall_baseline.yml --tags storage" success_msg: "✅ Heimdall: swap disabled" - name: Assert vm.swappiness=0 ansible.builtin.assert: that: h.swappiness | int == 0 fail_msg: "❌ vm.swappiness={{ h.swappiness }} — run heimdall_baseline.yml --tags sysctl" success_msg: "✅ Heimdall: vm.swappiness=0" - name: Assert bridge netfilter enabled ansible.builtin.assert: that: - h.bridge_iptables | int == 1 - h.bridge_ip6tables | int == 1 fail_msg: >- ❌ Bridge netfilter not fully enabled: bridge-nf-call-iptables={{ h.bridge_iptables }} bridge-nf-call-ip6tables={{ h.bridge_ip6tables }} Run heimdall_baseline.yml --tags sysctl. success_msg: "✅ Heimdall: bridge netfilter enabled" - name: Assert ip_forward enabled ansible.builtin.assert: that: h.ip_forward | int == 1 fail_msg: "❌ net.ipv4.ip_forward={{ h.ip_forward }} — run heimdall_baseline.yml --tags sysctl" success_msg: "✅ Heimdall: ip_forward=1" - name: Assert Docker log rotation configured ansible.builtin.assert: that: h.log_rotation_configured | bool fail_msg: "❌ Docker log rotation not configured — run heimdall_baseline.yml --tags docker" success_msg: "✅ Heimdall: Docker log rotation configured" - name: Assert Traefik container is running ansible.builtin.assert: that: h.traefik_running | bool fail_msg: "❌ Traefik container is not running — check: docker ps -a | grep traefik" success_msg: "✅ Heimdall: Traefik running" - name: Assert Redis container is running ansible.builtin.assert: that: h.redis_running | bool fail_msg: "❌ Redis container is not running — check: docker ps -a | grep redis" success_msg: "✅ Heimdall: Redis running" - name: Assert docker-socket-proxy container is running ansible.builtin.assert: that: h.socket_proxy_running | bool fail_msg: "❌ docker-socket-proxy container is not running — check: docker ps -a | grep socket" success_msg: "✅ Heimdall: docker-socket-proxy running"