227 lines
8.2 KiB
YAML
227 lines
8.2 KiB
YAML
---
|
|
# 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
|