--- # NTP Check Playbook # Read-only audit of time synchronisation across all hosts. # Reports the active NTP daemon, current clock offset in milliseconds, # and fires ntfy alerts for hosts that exceed the warn/critical thresholds. # Usage: ansible-playbook playbooks/ntp_check.yml # Usage: ansible-playbook playbooks/ntp_check.yml -e "host_target=rpi" # Usage: ansible-playbook playbooks/ntp_check.yml -e "warn_offset_ms=200 critical_offset_ms=500" - name: NTP Time Sync Check hosts: "{{ host_target | default('active') }}" gather_facts: yes ignore_unreachable: true vars: ntfy_url: "{{ ntfy_url | default('https://ntfy.sh/REDACTED_TOPIC') }}" report_dir: "/tmp/ntp_reports" warn_offset_ms: "{{ warn_offset_ms | default(500) }}" critical_offset_ms: "{{ critical_offset_ms | default(1000) }}" tasks: # ---------- Setup ---------- - name: Create NTP report directory ansible.builtin.file: path: "{{ report_dir }}" state: directory mode: '0755' delegate_to: localhost run_once: true # ---------- Detect active NTP daemon ---------- - name: Detect active NTP daemon ansible.builtin.shell: | if command -v chronyc >/dev/null 2>&1 && chronyc tracking >/dev/null 2>&1; then echo "chrony" elif timedatectl show-timesync 2>/dev/null | grep -q ServerName; then echo "timesyncd" elif timedatectl 2>/dev/null | grep -q "NTP service: active"; then echo "timesyncd" elif command -v ntpq >/dev/null 2>&1 && ntpq -p >/dev/null 2>&1; then echo "ntpd" else echo "unknown" fi register: ntp_impl changed_when: false failed_when: false # ---------- Chrony offset collection ---------- - name: Get chrony tracking info (full) ansible.builtin.shell: chronyc tracking 2>/dev/null register: chrony_tracking changed_when: false failed_when: false when: ntp_impl.stdout | trim == "chrony" - name: Parse chrony offset in ms ansible.builtin.shell: > chronyc tracking 2>/dev/null | grep "System time" | awk '{sign=($6=="slow")?-1:1; printf "%.3f", sign * $4 * 1000}' register: chrony_offset_raw changed_when: false failed_when: false when: ntp_impl.stdout | trim == "chrony" - name: Get chrony sync sources ansible.builtin.shell: chronyc sources -v 2>/dev/null | grep "^\^" | head -3 register: chrony_sources changed_when: false failed_when: false when: ntp_impl.stdout | trim == "chrony" # ---------- timesyncd offset collection ---------- - name: Get timesyncd status ansible.builtin.shell: timedatectl show-timesync 2>/dev/null || timedatectl 2>/dev/null register: timesyncd_status changed_when: false failed_when: false when: ntp_impl.stdout | trim == "timesyncd" - name: Parse timesyncd offset from journal (ms) ansible.builtin.shell: | raw=$(journalctl -u systemd-timesyncd --since "5 minutes ago" -n 20 --no-pager 2>/dev/null \ | grep -oE 'offset[=: ][+-]?[0-9]+(\.[0-9]+)?(ms|us|s)' \ | tail -1) if [ -z "$raw" ]; then echo "0" exit 0 fi num=$(echo "$raw" | grep -oE '[+-]?[0-9]+(\.[0-9]+)?') unit=$(echo "$raw" | grep -oE '(ms|us|s)$') if [ "$unit" = "us" ]; then awk "BEGIN {printf \"%.3f\", $num / 1000}" elif [ "$unit" = "s" ]; then awk "BEGIN {printf \"%.3f\", $num * 1000}" else printf "%.3f" "$num" fi register: timesyncd_offset_raw changed_when: false failed_when: false when: ntp_impl.stdout | trim == "timesyncd" # ---------- ntpd offset collection ---------- - name: Get ntpd peer table ansible.builtin.shell: ntpq -pn 2>/dev/null | head -10 register: ntpd_peers changed_when: false failed_when: false when: ntp_impl.stdout | trim == "ntpd" - name: Parse ntpd offset in ms ansible.builtin.shell: > ntpq -p 2>/dev/null | awk 'NR>2 && /^\*/ {printf "%.3f", $9; exit}' || echo "0" register: ntpd_offset_raw changed_when: false failed_when: false when: ntp_impl.stdout | trim == "ntpd" # ---------- Unified offset fact ---------- - name: Set unified ntp_offset_ms fact ansible.builtin.set_fact: ntp_offset_ms: >- {%- set impl = ntp_impl.stdout | trim -%} {%- if impl == "chrony" -%} {{ (chrony_offset_raw.stdout | default('0') | trim) | float }} {%- elif impl == "timesyncd" -%} {{ (timesyncd_offset_raw.stdout | default('0') | trim) | float }} {%- elif impl == "ntpd" -%} {{ (ntpd_offset_raw.stdout | default('0') | trim) | float }} {%- else -%} 0 {%- endif -%} # ---------- Determine sync status ---------- - name: Determine NTP sync status (OK / WARN / CRITICAL) ansible.builtin.set_fact: ntp_status: >- {%- if ntp_offset_ms | float | abs >= critical_offset_ms | float -%} CRITICAL {%- elif ntp_offset_ms | float | abs >= warn_offset_ms | float -%} WARN {%- else -%} OK {%- endif -%} # ---------- Per-host summary ---------- - name: Display per-host NTP summary ansible.builtin.debug: msg: | ========================================== NTP SUMMARY: {{ inventory_hostname }} ========================================== Daemon: {{ ntp_impl.stdout | trim }} Offset: {{ ntp_offset_ms }} ms Status: {{ ntp_status }} Thresholds: WARN >= {{ warn_offset_ms }} ms | CRITICAL >= {{ critical_offset_ms }} ms Raw details: {% if ntp_impl.stdout | trim == "chrony" %} --- chronyc tracking --- {{ chrony_tracking.stdout | default('n/a') }} --- chronyc sources --- {{ chrony_sources.stdout | default('n/a') }} {% elif ntp_impl.stdout | trim == "timesyncd" %} --- timedatectl show-timesync --- {{ timesyncd_status.stdout | default('n/a') }} {% elif ntp_impl.stdout | trim == "ntpd" %} --- ntpq peers --- {{ ntpd_peers.stdout | default('n/a') }} {% else %} (no NTP tool found — offset assumed 0) {% endif %} ========================================== # ---------- ntfy alert ---------- - name: Send ntfy alert for hosts exceeding warn threshold ansible.builtin.uri: url: "{{ ntfy_url }}" method: POST body: | Host {{ inventory_hostname }} has NTP offset of {{ ntp_offset_ms }} ms ({{ ntp_status }}). Daemon: {{ ntp_impl.stdout | trim }} Thresholds: WARN >= {{ warn_offset_ms }} ms | CRITICAL >= {{ critical_offset_ms }} ms Checked at {{ ansible_date_time.iso8601 }} headers: Title: "Homelab NTP Alert" Priority: "{{ 'urgent' if ntp_status == 'CRITICAL' else 'high' }}" Tags: "warning,clock" status_code: [200, 204] delegate_to: localhost failed_when: false when: ntp_status in ['WARN', 'CRITICAL'] # ---------- Per-host JSON report ---------- - name: Write per-host JSON NTP report ansible.builtin.copy: content: "{{ { 'timestamp': ansible_date_time.iso8601, 'hostname': inventory_hostname, 'ntp_daemon': ntp_impl.stdout | trim, 'offset_ms': ntp_offset_ms | float, 'status': ntp_status, 'thresholds': { 'warn_ms': warn_offset_ms, 'critical_ms': critical_offset_ms }, 'raw': { 'chrony_tracking': chrony_tracking.stdout | default('') | trim, 'chrony_sources': chrony_sources.stdout | default('') | trim, 'timesyncd_status': timesyncd_status.stdout | default('') | trim, 'ntpd_peers': ntpd_peers.stdout | default('') | trim } } | to_nice_json }}" dest: "{{ report_dir }}/{{ inventory_hostname }}_{{ ansible_date_time.date }}.json" delegate_to: localhost changed_when: false