#!/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 re
import sys

import lib.args
import lib.base
import lib.shell
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN

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

DESCRIPTION = """Sends ICMP ECHO_REQUEST packets to a network host using the system's built-in
ping command and reports round-trip time, round-trip variability and packet loss. By default it
reports CRITICAL only when the host is unreachable (no packet returns), so even high packet loss
stays OK as long as one reply arrives. The optional --rta-warning/--rta-critical (round-trip
average), --rtt-mdev-warning/--rtt-mdev-critical (round-trip variability, a jitter measure) and
--packet-loss-warning/--packet-loss-critical thresholds additionally alert on latency, jitter and
packet loss."""

DEFAULT_COUNT = 5  # icmp packets
DEFAULT_INTERVAL = 0.2  # seconds
DEFAULT_DEADLINE = 5  # seconds
DEFAULT_HOSTNAME = '127.0.0.1'

# iputils prints the summary line as (optional fields in brackets):
#   "%d packets transmitted, %d received[, +%d duplicates][, +%d corrupted]
#    [, +%d errors], %g%% packet loss, time %dms"
# The corrupted field is worded "corrupted" (not "checksum corrupted"); see iputils
# ping_output.c. Groups: 1 transmitted, 2 received, 4 duplicates, 6 corrupted, 8 errors,
# 9 packet loss, 10 total time.
STATISTICS_RE = re.compile(
    r'(\d+) packets transmitted, (\d+) received'
    r'(, \+?(\d+) duplicates)?'
    r'(, \+?(\d+) corrupted)?'
    r'(, \+?(\d+) errors)?'
    r', (\d+(?:\.\d+)?)% packet loss, time (\d+)'
)


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(
        '--count',
        help='Number of ECHO_REQUEST packets to send. Default: %(default)s',
        default=DEFAULT_COUNT,
        dest='COUNT',
        type=int,
    )

    parser.add_argument(
        '-H',
        '--hostname',
        help='Hostname or IP address to ping. Default: %(default)s',
        dest='HOSTNAME',
        default=DEFAULT_HOSTNAME,
    )

    parser.add_argument(
        '--interface',
        help='Interface name or source address to ping from (`ping -I`). '
        'Example: `--interface eth0`.',
        dest='INTERFACE',
        default=None,
    )

    parser.add_argument(
        '--interval',
        help='Interval between sending each packet, in seconds. '
        'Accepts real numbers with dot as decimal separator (regardless of locale). '
        'Default: %(default)s',
        default=DEFAULT_INTERVAL,
        dest='INTERVAL',
        type=float,
    )

    parser.add_argument(
        '--ipv4',
        help='Force IPv4.',
        dest='IPV4',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--ipv6',
        help=lib.args.help('--ipv6'),
        dest='IPV6',
        action='store_true',
        default=False,
    )

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

    parser.add_argument(
        '--packet-loss-critical',
        help='CRIT threshold for the packet loss, in percent. '
        'Supports Nagios ranges. '
        'Disabled by default.',
        dest='PACKET_LOSS_CRIT',
        default=None,
    )

    parser.add_argument(
        '--packet-loss-warning',
        help='WARN threshold for the packet loss, in percent. '
        'Supports Nagios ranges. '
        'Disabled by default.',
        dest='PACKET_LOSS_WARN',
        default=None,
    )

    parser.add_argument(
        '--packet-size',
        help='Number of data bytes to send (`ping -s`), excluding the 8-byte ICMP header. '
        'Example: `--packet-size 1472`.',
        dest='PACKET_SIZE',
        type=int,
        default=None,
    )

    parser.add_argument(
        '--rta-critical',
        help='CRIT threshold for the round-trip average, in milliseconds. '
        'Supports Nagios ranges. '
        'Disabled by default.',
        dest='RTA_CRIT',
        default=None,
    )

    parser.add_argument(
        '--rta-warning',
        help='WARN threshold for the round-trip average, in milliseconds. '
        'Supports Nagios ranges. '
        'Disabled by default.',
        dest='RTA_WARN',
        default=None,
    )

    parser.add_argument(
        '--rtt-mdev-critical',
        help='CRIT threshold for the round-trip variability (mdev, a jitter measure), '
        'in milliseconds. '
        'Supports Nagios ranges. '
        'Disabled by default.',
        dest='RTT_MDEV_CRIT',
        default=None,
    )

    parser.add_argument(
        '--rtt-mdev-warning',
        help='WARN threshold for the round-trip variability (mdev, a jitter measure), '
        'in milliseconds. '
        'Supports Nagios ranges. '
        'Disabled by default.',
        dest='RTT_MDEV_WARN',
        default=None,
    )

    parser.add_argument(
        '-t',
        '--timeout',
        help='Timeout in seconds before ping exits regardless of how many packets '
        'have been sent or received. '
        'Default: %(default)s',
        default=DEFAULT_DEADLINE,
        dest='DEADLINE',
        type=int,
    )

    parser.add_argument(
        '--ttl',
        help='IP Time To Live for the outgoing packets (`ping -t`). '
        'Example: `--ttl 64`.',
        dest='TTL',
        type=int,
        default=None,
    )

    args, _ = parser.parse_known_args()
    return args


def build_ping_call(args):
    """Build the argv for the system ping call.

    -n keeps the output numeric (no reverse DNS on replies), so the run is faster and the output
    deterministic. The optional flags mirror check_icmp's controls: -4/-6 (IP version), -t (TTL),
    -s (payload size) and -I (interface or source address). Example: ping -c 5 -i 0.2 -w 5 -q -n
    192.0.2.10
    """
    cmd = [
        'ping',
        '-c',
        str(args.COUNT),
        '-i',
        str(args.INTERVAL),
        '-w',
        str(args.DEADLINE),
        '-q',
        '-n',
    ]
    if args.IPV4:
        cmd.append('-4')
    if args.IPV6:
        cmd.append('-6')
    if args.TTL is not None:
        cmd += ['-t', str(args.TTL)]
    if args.PACKET_SIZE is not None:
        cmd += ['-s', str(args.PACKET_SIZE)]
    if args.INTERFACE:
        cmd += ['-I', args.INTERFACE]
    cmd.append(args.HOSTNAME)
    return cmd


def evaluate_thresholds(stats_match, rtt_match, args):
    """Evaluate the opt-in thresholds against the parsed ping output.

    Returns `(state, alerts)`: the worst threshold state and a list of human-readable breach
    strings (empty when nothing is over threshold). Packet loss comes from the summary line;
    round-trip average and variability (mdev) need the rtt line, which is absent when the host is
    unreachable (`rtt_match` is then None).
    """
    state = STATE_OK
    alerts = []

    packet_loss = stats_match.group(9)
    loss_state = lib.base.get_state(
        packet_loss, args.PACKET_LOSS_WARN, args.PACKET_LOSS_CRIT, _operator='range'
    )
    state = lib.base.get_worst(state, loss_state)
    if loss_state != STATE_OK:
        alerts.append(f'packet loss {packet_loss}% {lib.base.state2str(loss_state)}')

    if rtt_match is not None:
        rtt_avg = rtt_match.group(2)
        rta_state = lib.base.get_state(
            rtt_avg, args.RTA_WARN, args.RTA_CRIT, _operator='range'
        )
        state = lib.base.get_worst(state, rta_state)
        if rta_state != STATE_OK:
            alerts.append(
                f'round-trip average {rtt_avg}ms {lib.base.state2str(rta_state)}'
            )

        rtt_mdev = rtt_match.group(4)
        mdev_state = lib.base.get_state(
            rtt_mdev, args.RTT_MDEV_WARN, args.RTT_MDEV_CRIT, _operator='range'
        )
        state = lib.base.get_worst(state, mdev_state)
        if mdev_state != STATE_OK:
            alerts.append(
                f'jitter (mdev) {rtt_mdev}ms {lib.base.state2str(mdev_state)}'
            )

    return state, alerts


def build_perfdata(stats_match, rtt_match, args):
    """Build the performance-data string from the parsed ping output.

    rtt_avg, rtt_mdev and packet_loss carry their opt-in warn/crit; the rtt metrics are only
    emitted when the host is reachable (`rtt_match` is not None).
    """

    def count(group):
        # optional "+N" fields (duplicates/corrupted/errors) are absent when zero
        return group.replace('+', '') if group else 0

    perfdata = lib.base.get_perfdata(
        'transmitted',
        stats_match.group(1),
        uom=None,
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'received',
        stats_match.group(2),
        uom=None,
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'duplicates',
        count(stats_match.group(4)),
        uom=None,
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'checksum_corrupted',
        count(stats_match.group(6)),
        uom=None,
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'errors',
        count(stats_match.group(8)),
        uom=None,
        warn=None,
        crit=None,
        _min=0,
        _max=None,
    )
    perfdata += lib.base.get_perfdata(
        'packet_loss',
        stats_match.group(9),
        uom='%',
        warn=args.PACKET_LOSS_WARN,
        crit=args.PACKET_LOSS_CRIT,
        _min=0,
        _max=100,
    )
    perfdata += lib.base.get_perfdata(
        'time', stats_match.group(10), uom='ms', warn=None, crit=None, _min=0, _max=None
    )
    if rtt_match is not None:
        perfdata += lib.base.get_perfdata(
            'rtt_min',
            rtt_match.group(1),
            uom='ms',
            warn=None,
            crit=None,
            _min=0,
            _max=None,
        )
        perfdata += lib.base.get_perfdata(
            'rtt_avg',
            rtt_match.group(2),
            uom='ms',
            warn=args.RTA_WARN,
            crit=args.RTA_CRIT,
            _min=0,
            _max=None,
        )
        perfdata += lib.base.get_perfdata(
            'rtt_max',
            rtt_match.group(3),
            uom='ms',
            warn=None,
            crit=None,
            _min=0,
            _max=None,
        )
        perfdata += lib.base.get_perfdata(
            'rtt_mdev',
            rtt_match.group(4),
            uom='ms',
            warn=args.RTT_MDEV_WARN,
            crit=args.RTT_MDEV_CRIT,
            _min=0,
            _max=None,
        )
    return perfdata


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.IPV4 and args.IPV6:
        lib.base.cu('Use only one of --ipv4 and --ipv6.')

    # the hostname reaches ping as a positional argument; reject a value that ping
    # could read as an option (e.g. -f for flood)
    lib.base.coe(lib.shell.safe_cli_value(args.HOSTNAME, '--hostname'))

    # fetch data
    stdout, stderr, retc = lib.base.coe(lib.shell.shell_exec(build_ping_call(args)))
    if stderr or retc == 2:
        lib.base.cu(stderr)
    # stdout:
    #   PING 192.0.2.10 (192.0.2.10) 56(84) bytes of data.
    #
    #   --- 192.0.2.10 ping statistics ---
    #   5 packets transmitted, 5 received, 0% packet loss, time 803ms
    #   rtt min/avg/max/mdev = 6.724/13.682/15.856/3.488 ms

    # If ping does not receive any reply packets at all it will exit with code 1.
    # If a packet count and deadline are both specified, and fewer than count packets are received
    # by the time the deadline has arrived, it will also exit with code 1.
    # On other error it exits with code 2. Otherwise it exits with code 0.

    # Since we want to be as tolerant as possible, if we send burst pings and at least
    # one packet makes its way back, we assume the host is alive. So we don't rely on the
    # return code of `ping` (any longer).
    # See https://github.com/Linuxfabrik/monitoring-plugins/issues/691 for details.

    # init some vars
    # Throwing CRIT instead of WARN beacuse of the fact that this check will mainly be used
    # for checking host-liveliness [OK=UP, CRIT=DOWN].
    state = STATE_CRIT if re.search(r'\b0 received', stdout) else STATE_OK
    down = state == STATE_CRIT

    # analyze data:
    result = stdout.splitlines()
    if not result[0] or not result[3]:
        lib.base.cu('Unexpected output from ping.')

    # line 0: 'PING www.linuxfabrik.ch (192.0.2.10) 56(84) bytes of data.' -> host
    host = re.search(r'G (.*?)\(', result[0]).group(1).strip()  # regex: 45 steps

    # line 3 (see STATISTICS_RE):
    # '5 packets transmitted, 5 received, 0% packet loss, time 803ms'
    stats_match = STATISTICS_RE.search(result[3])
    detail = result[3] + '. '

    # line 4 (only when the host is reachable):
    # 'rtt min/avg/max/mdev = 8.926/11.367/17.350/3.184 ms'
    rtt_match = None
    if result[4] and not result[4].startswith('pipe '):
        rtt_match = re.search(
            r'= (\d+\.\d+)/(\d+\.\d+)/(\d+\.\d+)/(\d+\.\d+)', result[4]
        )  # regex: 26 steps
        detail += result[4]

    # apply the opt-in thresholds and build the perfdata
    threshold_state, alerts = evaluate_thresholds(stats_match, rtt_match, args)
    state = lib.base.get_worst(state, threshold_state)
    perfdata = build_perfdata(stats_match, rtt_match, args)

    # build the message: lead with any threshold breaches, then the ping detail
    msg = 'Destination host unreachable. ' if down else ''
    msg += f'PING {host}: '
    if alerts:
        msg += ', '.join(alerts) + '. '
    msg += detail

    # over and out
    lib.base.oao(
        msg, state, perfdata, always_ok=args.ALWAYS_OK, no_perfdata=args.NO_PERFDATA
    )


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