Files
homelab-optimized/ansible/automation/playbooks/ntp_check.yml
Gitea Mirror Bot 0406b42712
Some checks failed
Documentation / Deploy to GitHub Pages (push) Has been cancelled
Documentation / Build Docusaurus (push) Has been cancelled
Sanitized mirror from private repository - 2026-04-08 03:17:08 UTC
2026-04-08 03:17:08 +00:00

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