---
# 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
