#!/usr/lib64/linuxfabrik-monitoring-plugins/venv/bin/python
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author:  Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
#          https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.

# https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.md

"""See the check's README for more details."""

import argparse
import sys

import lib.args
import lib.base
import lib.human
import lib.lftest
import lib.shell
from lib.globals import STATE_OK, STATE_UNKNOWN, STATE_WARN

__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026102402'

DESCRIPTION = """Checks how far the system clock is off from the time the Windows Time
service (W32Time) derives from its time source, in milliseconds, and how long ago it
last synchronized. Alerts when the offset or the time since the last synchronization
exceeds the configured thresholds, when the stratum is too high, when no time source is
used, when a leap second is announced, or when the last synchronization failed. Reads
the output in English and German."""

DEFAULT_CRIT = '60000'  # ms offset, 1 minute
DEFAULT_CRIT_AGE = '0D'
# Microsoft requires a host to be no further than stratum 5 from a highly accurate time
# source for 50 ms accuracy, as `w32tm /query /status` shows it ("Support boundary for
# high accuracy time", learn.microsoft.com; #794)
DEFAULT_STRATUM = 6
DEFAULT_TIMEOUT = 8
DEFAULT_WARN = '300'  # ms offset
# W32Time polls every 2^15 s (9.1 h) at most by default on domain members and
# stand-alone hosts (MaxPollInterval 15, "Windows Time Service Tools and Settings",
# learn.microsoft.com), so 10 h means at least one missed synchronization. A server with
# the SpecialInterval flag (0x1, as in the default "time.windows.com,0x9") is polled
# every SpecialPollInterval s instead, 604800 on a stand-alone host; MaxPollInterval
# caps that since build 1703, so only Windows Server 2016 needs `--warning-age` raised.
DEFAULT_WARN_AGE = '10h'

# w32tm prints its labels in the display language of the account it runs as. The values
# keep their format in every language ("0.0070272s", "2 (...)"), so only the labels need
# a translation. There is no way to ask for English per call, as LC_ALL does on Linux:
# SetProcessPreferredUILanguages(), SetThreadPreferredUILanguages() and
# SetThreadUILanguage() are not inherited by the child, and neither `chcp` nor the
# process creation flags change its language. Only the system preferred UI language
# does (LocalSystem follows it after a reboot). Verified with Icinga 2 v2.16.5 running
# w32tm as LocalSystem on Windows Server 2025 with the de-DE and en-US language packs;
# the labels below are from that host.
LABELS = {
    'ClockRate': 'clock_rate',
    'Last Sync Error': 'last_sync_error',
    'Leap Indicator': 'leap_indicator',
    'Phase Offset': 'phase_offset',
    'Precision': 'precision',
    'Root Delay': 'root_delay',
    'Root Dispersion': 'root_dispersion',
    'Source': 'source',
    'Stratum': 'stratum',
    'Time since Last Good Sync Time': 'time_since_last_good_sync_time',
    # de-DE
    'Letzter Synchronierungsfehler': 'last_sync_error',
    'Phasendifferenz': 'phase_offset',
    'Präzision': 'precision',
    'Quelle': 'source',
    'Sprungindikator': 'leap_indicator',
    'Stammabweichung': 'root_dispersion',
    'Stammverzögerung': 'root_delay',
    'Taktfrequenz': 'clock_rate',
    'Zeit seit der letzten erfolgr. Synchronisierungszeit': (
        'time_since_last_good_sync_time'
    ),
}


def parse_args():
    """Parse command line arguments using argparse."""
    parser = argparse.ArgumentParser(
        description=DESCRIPTION,
        epilog=lib.args.epilog(__file__),
        formatter_class=lib.args.HelpFormatter,
    )

    parser.add_argument(
        '-V',
        '--version',
        action='version',
        version=f'%(prog)s: v{__version__} by {__author__}',
    )

    parser.add_argument(
        '--always-ok',
        help=lib.args.help('--always-ok'),
        dest='ALWAYS_OK',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '-c',
        '--critical',
        help='CRIT threshold for the offset of the system clock, fast or slow, in '
        'milliseconds. Supports Nagios ranges. '
        'Default: %(default)s',
        dest='CRIT',
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--critical-age',
        help='CRIT threshold for the time since W32Time last synchronized the clock. '
        'A duration such as `30m`, `2h` or `1D`; `0D` disables it. '
        'Default: %(default)s',
        dest='CRIT_AGE',
        type=lib.args.duration,
        default=DEFAULT_CRIT_AGE,
    )

    parser.add_argument(
        '--no-perfdata',
        help=lib.args.help('--no-perfdata'),
        dest='NO_PERFDATA',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--stratum',
        help=lib.args.help('--stratum') + ' Default: %(default)s',
        dest='STRATUM',
        type=int,
        default=DEFAULT_STRATUM,
    )

    parser.add_argument(
        '--test',
        help=lib.args.help('--test'),
        dest='TEST',
        type=lib.args.csv,
    )

    parser.add_argument(
        '--timeout',
        help=lib.args.help('--timeout') + ' Default: %(default)s (seconds)',
        dest='TIMEOUT',
        type=int,
        default=DEFAULT_TIMEOUT,
    )

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for the offset of the system clock, fast or slow, in '
        'milliseconds. Supports Nagios ranges. '
        'Default: %(default)s',
        dest='WARN',
        default=DEFAULT_WARN,
    )

    parser.add_argument(
        '--warning-age',
        help='WARN threshold for the time since W32Time last synchronized the clock. '
        'A duration such as `30m`, `2h` or `1D`; `0D` disables it. '
        'Default: %(default)s',
        dest='WARN_AGE',
        type=lib.args.duration,
        default=DEFAULT_WARN_AGE,
    )

    args, _ = parser.parse_known_args()
    return args


def main():
    """The main function. This is where the magic happens."""

    # parse the command line
    try:
        args = parse_args()
    except SystemExit:
        sys.exit(STATE_UNKNOWN)

    if args.TEST is None and not lib.base.WINDOWS:
        lib.base.cu('This check runs on Windows only.')

    # fetch data
    if args.TEST is None:
        cmd = ['w32tm', '/query', '/status', '/verbose']
        success, result = lib.shell.shell_exec(cmd, timeout=args.TIMEOUT)
        if not success:
            if result.startswith('Timeout after'):
                lib.base.oao(
                    f'Timeout after {args.TIMEOUT}s while querying the Windows Time'
                    ' Service.',
                    STATE_WARN,
                    always_ok=args.ALWAYS_OK,
                )
            lib.base.cu(result)
        stdout, _, retc = result
    else:
        # do not call the command, put in test data
        stdout, _, retc = lib.lftest.test(args.TEST)
    if retc != 0:
        # ERROR_SERVICE_NOT_ACTIVE, measured on a Windows 10 Pro 22H2 host outside a
        # domain: w32time starts on domain join only, otherwise just briefly for the
        # weekly SynchronizeTime task. The code stays the same in every display
        # language.
        if '0x80070426' in stdout:
            lib.base.oao(
                'The Windows Time service (w32time) is not running, so this host does'
                ' not keep its clock in sync. Set it to start automatically'
                ' (`sc.exe config w32time start= auto`) and start it.',
                STATE_WARN,
                always_ok=args.ALWAYS_OK,
            )
        # E_ACCESSDENIED: w32time lets administrators, services and interactive
        # sessions query it, not a standard user who logged in remotely. Measured on
        # Windows 10, 11 and Server 2016 over SSH.
        if '0x80070005' in stdout:
            lib.base.cu(
                'Access denied while querying the Windows Time service. The account'
                ' the check runs as may not query it from a remote login such as SSH'
                ' or WinRM; a monitoring agent running as a service, an interactive'
                ' session and an administrator may.'
            )
        lib.base.cu(stdout)  # Windows prints the error to stdout

    # init some vars
    msg = []
    state = STATE_OK
    perfdata = ''

    clock_rate = 0
    leap_indicator = 0
    phase_offset = 0
    precision = 0
    root_delay = 0
    root_dispersion = 0
    source = ''
    stratum = None
    sync_age = None

    # analyze data
    # w32tm wraps a long value onto a line of its own, without a label ("... nur
    # veraltete Zeitdaten" / "verfügbar waren.)"), measured on Windows Server 2019.
    # Join it back so the value is complete where the message quotes it.
    lines = []
    for line in stdout.splitlines():
        line = line.strip()
        if line and ':' not in line and lines and lines[-1]:
            lines[-1] += f' {line}'
        else:
            lines.append(line)
    for line in lines:
        label, _, value = line.partition(':')
        key = LABELS.get(label.strip())
        if key is None:
            continue
        value = value.strip()
        try:
            # the number in front of an explanation, as in "2 (Sync)" or "0(no warning)"
            number = value.split('(')[0].split()[0] if value else ''
            if key == 'stratum':
                stratum = int(number)
            elif key == 'leap_indicator':
                leap_indicator = int(number)
                if leap_indicator != 0:
                    msg.append(
                        f'{line.replace("(", " (")} {lib.base.state2str(STATE_WARN)}'
                    )
                    state = STATE_WARN
            elif key == 'precision':
                precision = int(number)
            elif key == 'last_sync_error':
                if int(number) != 0:
                    msg.append(f'{line} {lib.base.state2str(STATE_WARN)}')
                    state = STATE_WARN
            elif key == 'source':
                # "time.windows.com,0x8", "Local CMOS Clock", with flags cut off
                source = value.split(',')[0].strip()
            elif key == 'time_since_last_good_sync_time':
                sync_age = round(float(number.rstrip('s')))
            else:
                # clock_rate, phase_offset, root_delay, root_dispersion: seconds, as ms
                milliseconds = float(number.rstrip('s')) * 1000
                if key == 'clock_rate':
                    clock_rate = milliseconds
                elif key == 'phase_offset':
                    phase_offset = milliseconds
                elif key == 'root_delay':
                    root_delay = milliseconds
                else:
                    root_dispersion = milliseconds
        except (IndexError, ValueError):
            # a known label without the number in front, as in "Stratum: (x)"
            lib.base.cu(
                'Unable to read the output of `w32tm /query /status /verbose`,'
                f' the line "{line}" carries no number.\n\n{stdout}',
                traceback=False,
            )

    # Without these two lines there is nothing to judge the synchronization by. Say so
    # instead of reporting a host whose output could not be read as healthy.
    if stratum is None or sync_age is None:
        lib.base.cu(
            'Unable to read the output of `w32tm /query /status /verbose`. It is'
            ' printed in a display language this check does not know. Supported:'
            ' English, German.\n\n' + stdout
        )

    # "Phase Offset" already carries the sign of RFC 5905 and the other ntp-* checks:
    # positive means the system clock is slow. Verified on Windows Server 2025 by
    # moving the clock 700 ms ahead (Phase Offset -0.089 s) and behind (+0.081 s).
    offset = round(phase_offset, 6) or 0.0

    # build the message
    if stratum == 0:
        # not synchronized: there is no offset to judge. Either W32Time uses no NTP
        # server ("Local CMOS Clock"), or it has not synchronized with its server yet,
        # as for a while after a clock step. Seen on Windows Server 2025 after moving
        # the clock 60 s ahead and `w32tm /resync`.
        state = lib.base.get_worst(state, STATE_WARN)
        if ' ' not in source and ('.' in source or ':' in source):
            head = f'Not synchronized with NTP server {source}'
        else:
            head = f'No NTP server used (Source: {source or "none"})'
        head += lib.base.state2str(STATE_WARN, prefix=' ')
    else:
        # the thresholds apply to the offset in both directions
        offset_state = lib.base.get_state(
            abs(offset), args.WARN, args.CRIT, _operator='range'
        )
        state = lib.base.get_worst(state, offset_state)
        head = (
            f'System clock is {abs(round(offset, 3))}ms'
            f' {"slow" if offset > 0 else "fast"}'
            f'{lib.base.state2str(offset_state, prefix=" ")} compared to the time'
            f' W32Time derives from {source}, Stratum is {stratum}'
        )
        if stratum >= args.STRATUM:
            stratum_state = STATE_WARN
            state = lib.base.get_worst(state, stratum_state)
            head += f' (is >= {args.STRATUM} {lib.base.state2str(stratum_state)})'
        age_state = lib.base.get_state(
            sync_age,
            int(args.WARN_AGE) or None,
            int(args.CRIT_AGE) or None,
        )
        state = lib.base.get_worst(state, age_state)
        head += (
            f', last synchronized {lib.human.seconds2human(sync_age)} ago'
            f'{lib.base.state2str(age_state, prefix=" ")}'
        )
    msg.insert(0, head)

    perfdata += lib.base.get_perfdata(
        'clock_rate',
        round(clock_rate, 6),
        uom='ms',
        warn=None,
        crit=None,
        _min=None,
        _max=None,
    )
    if stratum:
        # without a time source, "Phase Offset" is a leftover of an earlier sync
        perfdata += lib.base.get_perfdata(
            'offset',
            offset,
            uom='ms',
            warn=lib.base.symmetric_range(args.WARN),
            crit=lib.base.symmetric_range(args.CRIT),
            _min=None,
            _max=None,
        )
    perfdata += lib.base.get_perfdata(
        'precision',
        precision,
        uom=None,
        warn=None,
        crit=None,
        _min=None,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'root_delay',
        round(root_delay, 6),
        uom='ms',
        warn=None,
        crit=None,
        _min=None,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'root_dispersion',
        round(root_dispersion, 6),
        uom='ms',
        warn=None,
        crit=None,
        _min=None,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'stratum',
        stratum,
        uom=None,
        # --stratum warns from this stratum on, a perfdata range above its end
        warn=args.STRATUM - 1,
        crit=None,
        _min=0,
        _max=16,
    )
    perfdata += lib.base.get_perfdata(
        'sync_age',
        sync_age,
        uom='s',
        warn=int(args.WARN_AGE) or None,
        crit=int(args.CRIT_AGE) or None,
        _min=0,
        _max=None,
    )

    # over and out
    details = '\n'.join(stdout.splitlines())
    lib.base.oao(
        ', '.join(msg) + '\n\n' + details,
        state,
        perfdata,
        always_ok=args.ALWAYS_OK,
        no_perfdata=args.NO_PERFDATA,
    )


if __name__ == '__main__':
    try:
        main()
    except Exception:
        lib.base.cu()
