A Nightly Network Audit Report with Ansible: Facts, Compliance and a Dated HTML Page

One read-only playbook that audits six IOS XE devices, renders a dated Markdown and HTML report, survives an unreachable device, and runs from a systemd timer. With what I actually scheduled and what I removed afterwards.

Lab diagram: a systemd timer fires at 02:30 every night and starts nightly-audit.sh, which activates the Python venv, runs 220-nightly-audit.yml, prunes audit files older than 30 days and exi

Most of the playbooks in this series answer a question once: is NTP right, is the services standard in place, did the upgrade break anything. The useful version of those checks runs every night without anyone asking and leaves a page behind that says what it found. This post builds that: one read-only playbook that collects facts and compliance from every device, renders a dated Markdown and HTML report, and a systemd timer to run it.

I built this in my home lab against the same six IOS XE devices as the last few parts. Everything below was run, including the timer. I'll also say what I removed afterwards, because a scheduled job you forgot about is its own kind of drift.

It reuses the rules from part 20's services check and follows part 21's upgrade workflow.

Versions: ansible-core 2.21, cisco.ios 11.6, IOS XE 17.18.2 on five routers and one L2 switch image (br1-acc1).

Lab diagram: a systemd timer fires at 02:30 every night and starts nightly-audit.sh, which activates the Python venv, runs 220-nightly-audit.yml, prunes audit files older than 30 days and exits with the playbook's return code. Play 1 (Collect) runs read-only show commands on six devices (hq-core1, hq-core2, hq-edge1, br1-edge1, br2-edge1 and the L2 switch br1-acc1) and builds an audit fact per host covering version, image, serial and 10 rule results. Play 2 (Report) runs on the control node, writes dated Markdown and HTML reports and points latest.md and latest.html at them. The HTML table shows passes in teal and the ntp-synced rule failing on every device, because no NTP server was reachable. A greyed test device, br3-edge1, shows how an unreachable host appears as "Not reached". Exit 0 means a clean run; exit 2 means a device failed, so the job fails too.
The nightly audit as designed, with the systemd timer at 02:30. In the lab I ran it by hand and removed the timer afterwards.

The shape of it

Two plays in one playbook:

  1. Collect runs against every device. It gathers facts, reads the running config and a few show commands, and evaluates the rules into a per-host audit fact. Nothing is written to the devices.
  2. Report runs on the control node. It reads every host's audit fact through hostvars and renders two templates into reports/audit/, named by date, with a latest link.

Splitting it this way matters for the bad night. If a device can't be reached, it drops out of the first play, but the second play still runs and the report lists it as not reached. A report that silently skips a dead router is worse than no report.

The playbook

# playbooks/220-nightly-audit.yml
---
# Nightly audit: read-only. Collects facts and services compliance from every
# device and writes one dated report (Markdown + HTML) on the control node.
- name: Collect
  hosts: sites
  gather_facts: false
  vars_files:
    - ../vars/services.yml
  vars:
    snmp_level: "{{ svc_snmp_level | default('priv') }}"
  tasks:
    - name: Facts
      cisco.ios.ios_facts:
        gather_subset: default

    - name: Read what the rules need
      cisco.ios.ios_command:
        commands:
          - show running-config
          - show snmp user
          - show ntp status
          - show ip interface brief
      register: out

    - name: Evaluate the rules
      ansible.builtin.set_fact:
        audit:
          version: "{{ ansible_net_version }}"
          image: "{{ ansible_net_image }}"
          serial: "{{ ansible_net_serialnum | default('n/a') }}"
          up_interfaces: "{{ out.stdout[3].splitlines() | select('search', '\\sup\\s+up\\s*$') | list | length }}"
          rules:
            - {rule: ntp-server, ok: "{{ cfg is search('^ntp server ' ~ ntp_server ~ '$', multiline=True) }}"}
            - {rule: syslog-hosts, ok: "{{ ([syslog_server] + role_syslog_servers) | map('regex_replace', '^', 'logging host ') | reject('in', cfg_lines) | list | length == 0 }}"}
            - {rule: log-buffer, ok: "{{ ('logging buffered ' ~ svc_logging_buffer ~ ' informational') in cfg_lines }}"}
            - {rule: http-off, ok: "{{ cfg is not search('^ip http (secure-)?server$', multiline=True) }}"}
            - {rule: vty-timeout, ok: "{{ cfg is search('^line vty 0 4\\n(?: .*\\n)*? exec-timeout ' ~ svc_vty_exec_timeout ~ ' 0$', multiline=True) }}"}
            - {rule: login-banner, ok: "{{ cfg is search('^banner login ', multiline=True) }}"}
            - {rule: no-communities, ok: "{{ cfg is not search('^snmp-server community ', multiline=True) }}"}
            - {rule: snmpv3-user, ok: "{{ out.stdout[1] is search('User name: ' ~ svc_snmp_user ~ '$', multiline=True) }}"}
            - {rule: aaa-local-fallback, ok: "{{ cfg is search('^aaa authentication login default group \\S+ local$', multiline=True) }}"}
            - {rule: ntp-synced, ok: "{{ out.stdout[2] is search('Clock is synchronized') }}"}
      vars:
        cfg: "{{ out.stdout[0] }}"
        cfg_lines: "{{ out.stdout[0].splitlines() }}"

- name: Report
  hosts: localhost
  gather_facts: false
  vars:
    run_date: "{{ now(utc=true).strftime('%Y-%m-%d') }}"
    run_time: "{{ now(utc=true).strftime('%Y-%m-%d %H:%M UTC') }}"
    report_dir: "{{ playbook_dir }}/../reports/audit"
    devices: "{{ groups['sites'] | sort }}"
    reached: "{{ devices | select('in', hostvars | dict2items | selectattr('value.audit', 'defined') | map(attribute='key') | list) | list }}"
  tasks:
    - name: Report folder
      ansible.builtin.file:
        path: "{{ report_dir }}"
        state: directory
        mode: "0755"

    - name: Render Markdown and HTML
      ansible.builtin.template:
        src: "../templates/audit.{{ item }}.j2"
        dest: "{{ report_dir }}/audit-{{ run_date }}.{{ item }}"
        mode: "0644"
      loop: [md, html]

    - name: Point latest at today
      ansible.builtin.file:
        src: "audit-{{ run_date }}.{{ item }}"
        dest: "{{ report_dir }}/latest.{{ item }}"
        state: link
        force: true
      loop: [md, html]

    - name: Summary
      ansible.builtin.debug:
        msg: "{{ reached | length }}/{{ devices | length }} devices reached, report {{ report_dir }}/audit-{{ run_date }}.html"

The rules are part 20's check, cut down to the ones that mean something every night. A few notes:

  • reached is built from the hosts that actually have an audit fact, not from the inventory. That is how the report knows who is missing.
  • The date comes from now(utc=true), so the filename doesn't depend on the control node's timezone.
  • Facts give version, image and serial for free. ansible_net_model came back empty on these images, so I used the image path instead of a model column.

The templates

The Markdown version is the one I'd commit to Git or paste into a ticket:

{# templates/audit.md.j2 #}
# Network audit {{ run_date }}

Run {{ run_time }}. Devices reached: {{ reached | length }} of {{ devices | length }}.
{% set missing = devices | difference(reached) %}
{% if missing %}Not reached: {{ missing | join(', ') }}
{% endif %}

## Inventory

| Device | Image | Version | Serial | Up interfaces | Rules failing |
|---|---|---|---|---|---|
{% for h in reached %}
{% set a = hostvars[h].audit %}
| {{ h }} | {{ a.image }} | {{ a.version }} | {{ a.serial }} | {{ a.up_interfaces }} | {{ a.rules | rejectattr('ok') | list | length }} |
{% endfor %}

## Failing rules

{% for h in reached %}
{% set bad = hostvars[h].audit.rules | rejectattr('ok') | map(attribute='rule') | list %}
- {{ h }}: {{ bad | join(', ') if bad else 'none' }}
{% endfor %}

The HTML version is one table with a column per rule, green for pass and red for fail. It has no external CSS or JavaScript, so it opens straight from a file share or an email attachment:

{# templates/audit.html.j2 #}
<!doctype html>
<html><head><meta charset="utf-8"><title>Network audit {{ run_date }}</title>
<style>body{font-family:sans-serif;margin:2em}table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:4px 8px}.ok{background:#e6f4ea}.bad{background:#fce8e6}</style>
</head><body>
<h1>Network audit {{ run_date }}</h1>
<p>Run {{ run_time }}. Devices reached: {{ reached | length }} of {{ devices | length }}.
{% set missing = devices | difference(reached) %}{% if missing %} Not reached: <b>{{ missing | join(', ') }}</b>{% endif %}</p>
{% set rules = hostvars[reached[0]].audit.rules | map(attribute='rule') | list if reached else [] %}
<table><tr><th>Device</th><th>Version</th><th>Serial</th>{% for r in rules %}<th>{{ r }}</th>{% endfor %}</tr>
{% for h in reached %}{% set a = hostvars[h].audit %}
<tr><td>{{ h }}</td><td>{{ a.version }}</td><td>{{ a.serial }}</td>{% for r in a.rules %}<td class="{{ 'ok' if r.ok else 'bad' }}">{{ 'pass' if r.ok else 'FAIL' }}</td>{% endfor %}</tr>
{% endfor %}</table>
</body></html>

First run

$ ansible-playbook -i site-inv/hosts.yml playbooks/220-nightly-audit.yml

TASK [Summary]
ok: [localhost] => msg: 6/6 devices reached, report ~/ansible-lab/reports/audit/audit-2026-11-01.html

br1-acc1   : ok=3  changed=0  unreachable=0  failed=0
br1-edge1  : ok=3  changed=0  unreachable=0  failed=0
br2-edge1  : ok=3  changed=0  unreachable=0  failed=0
hq-core1   : ok=3  changed=0  unreachable=0  failed=0
hq-core2   : ok=3  changed=0  unreachable=0  failed=0
hq-edge1   : ok=3  changed=0  unreachable=0  failed=0
localhost  : ok=4  changed=3  unreachable=0  failed=0

real    0m26.503s

Every device shows changed=0. The only changes are the report folder, the files and the links on the control node. About 26 seconds for six devices.

The Markdown it wrote:

# Network audit 2026-11-01

Run 2026-11-01 04:32 UTC. Devices reached: 6 of 6.

## Inventory

| Device | Image | Version | Serial | Up interfaces | Rules failing |
|---|---|---|---|---|---|
| br1-acc1 | unix:/iol/iol.bin | 17.18.2 | 1 | 5 | 1 |
| br1-edge1 | unix:/iol/iol.bin | 17.18.2 | 2 | 4 | 1 |
| br2-edge1 | unix:/iol/iol.bin | 17.18.2 | 3 | 3 | 1 |
| hq-core1 | unix:/iol/iol.bin | 17.18.2 | 4 | 4 | 1 |
| hq-core2 | unix:/iol/iol.bin | 17.18.2 | 5 | 3 | 1 |
| hq-edge1 | unix:/iol/iol.bin | 17.18.2 | 6 | 5 | 1 |

## Failing rules

- br1-acc1: ntp-synced
- br1-edge1: ntp-synced
...

Two things to read honestly here. The serial numbers 1 to 6 are what the lab image reports; real hardware gives a proper serial, which is the column auditors usually ask for. And ntp-synced fails on every device because the lab has no reachable NTP server for the routers, the same result as in part 20. The report is right to flag it. On a real network that line is the one that catches a broken time source before log timestamps stop lining up.

The bad night: a device that doesn't answer

To test the missing-device path I added a second inventory file with a fake br3-edge1 at an address nothing answers on, and ran the same playbook with both inventories:

fatal: [br3-edge1]: FAILED! => {"changed": false, "msg": "[Errno None] Unable to connect to port 22 on 10.66.10.99"}
    "msg": "6/7 devices reached, report ~/ansible-lab/reports/audit/audit-2026-11-01.html"

br3-edge1  : ok=0  changed=0  unreachable=0  failed=1
localhost  : ok=4  changed=1  unreachable=0  failed=0
exit=2

And the top of the report:

Run 2026-11-01 04:32 UTC. Devices reached: 6 of 7.
Not reached: br3-edge1

The report still got written, it names the device, and ansible-playbook exits 2. That exit code is what the scheduler sees, so a failed night shows up as a failed job too. I removed the fake inventory afterwards and re-ran so the day's report is the clean six-device one.

Scheduling it

There are two normal ways to run this every night on a Linux control node: a cron entry or a systemd timer. Either way, wrap the run in a small script so the schedule line stays short and the environment is explicit:

# bin/nightly-audit.sh
#!/usr/bin/env bash
# Run the nightly audit and keep 30 days of reports.
set -u
cd "$HOME/ansible-lab"
. "$HOME/ansible-venv/bin/activate"
export ANSIBLE_DEPRECATION_WARNINGS=False ANSIBLE_NOCOLOR=1
ansible-playbook -i site-inv/hosts.yml playbooks/220-nightly-audit.yml
rc=$?
find reports/audit -name 'audit-*' -mtime +30 -delete
echo "audit exit code $rc"
exit $rc

The find ... -mtime +30 -delete keeps a month of reports. Without it the folder grows forever. The latest links aren't matched by audit-*, so they stay.

With cron, it is one line in crontab -e:

30 2 * * * $HOME/ansible-lab/bin/nightly-audit.sh >> $HOME/ansible-lab/reports/audit/cron.log 2>&1

I didn't install that one. I used a systemd user timer instead, because it logs to the journal, shows the last result in systemctl status, and can catch up on a missed run. Two unit files in ~/.config/systemd/user/:

# nightly-audit.service
[Unit]
Description=Nightly network audit (Ansible)

[Service]
Type=oneshot
ExecStart=%h/ansible-lab/bin/nightly-audit.sh
# nightly-audit.timer
[Unit]
Description=Run the network audit every night

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

Persistent=true runs a missed job at the next boot, and RandomizedDelaySec spreads the start over five minutes so several audits don't all hit the devices at 02:30 sharp. Enabling it and checking the schedule:

$ systemd-analyze calendar "*-*-* 02:30:00"
Normalized form: *-*-* 02:30:00
    Next elapse: Mon 2026-11-02 02:30:00 UTC

$ systemctl --user enable --now nightly-audit.timer
$ systemctl --user list-timers nightly-audit.timer
NEXT                        LEFT LAST PASSED UNIT                ACTIVATES
Mon 2026-11-02 02:32:59 UTC  22h -         - nightly-audit.timer nightly-audit.service

The 02:32:59 is the random delay at work. I didn't wait 22 hours to watch it fire. Instead I started the service by hand, which runs exactly what the timer would:

$ systemctl --user start nightly-audit.service
$ systemctl --user status nightly-audit.service
○ nightly-audit.service - Nightly network audit (Ansible)
     Active: inactive (dead) since Sun 2026-11-01 04:33:00 UTC
TriggeredBy: ● nightly-audit.timer
    Process: 700028 ExecStart=.../ansible-lab/bin/nightly-audit.sh (code=exited, status=0/SUCCESS)

$ journalctl --user -u nightly-audit.service -o cat
    "msg": "6/6 devices reached, report ~/ansible-lab/reports/audit/audit-2026-11-01.html"
audit exit code 0
Finished nightly-audit.service - Nightly network audit (Ansible).

That proves the job runs under systemd with its own environment, which is the usual place these break: a missing venv, a vault password file the service can't read, or SSH host keys that only exist for your interactive shell. It worked here because the vault password file and known_hosts are in the same user's home.

What it does not prove is that the timer fired at 02:30 on its own. I didn't see that happen, so I'm not showing it.

One more catch. A user timer only runs while that user has a systemd session, and this account showed:

Linger=no

With linger off, the timer stops when you log out. For a real control node, either run loginctl enable-linger <user> or install the units as system units with User= set. I didn't change it in the lab.

What I left running

Nothing. The lab doesn't need a nightly audit, so after the manual run I removed the schedule:

$ systemctl --user disable --now nightly-audit.timer
Removed '.../timers.target.wants/nightly-audit.timer'.
$ rm ~/.config/systemd/user/nightly-audit.service ~/.config/systemd/user/nightly-audit.timer
$ systemctl --user daemon-reload
$ systemctl --user list-timers --all | grep -c nightly
0
$ crontab -l
no crontab for j

The playbook, templates, wrapper script and the day's report stay in the project. Putting the schedule back is two files and one enable --now.

Where to take it

  • Commit latest.md to Git each night so the diff between two days shows exactly what changed. That is where the series goes next.
  • Have the wrapper send the HTML, or a link to it, only when the exit code isn't 0 or a rule count goes up. A daily email nobody reads trains everyone to ignore it.
  • Add part 21's pre-check snapshot (interfaces, OSPF, BGP, routes) as a second section, and compare it with the previous night's.

Part 23 builds a network inventory from facts and parsed show output: interfaces, CDP neighbors, OSPF and BGP state. The rest of the series is on the series page.

Read next