#!/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 json
import socket
import sys
import time

import lib.args
import lib.base
import lib.lftest
import lib.net
from lib.globals import STATE_UNKNOWN, STATE_WARN

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

DESCRIPTION = """Performs a DNS lookup and resolves a hostname to one or more IP addresses.
Queries the name servers configured on the local machine (e.g. those listed in
/etc/resolv.conf) and measures the lookup response time. Works with both IPv4 and IPv6.
Alerts when the name does not resolve, the lookup returns no address or times out, or
the lookup time exceeds the warning or critical threshold."""

DEFAULT_HOSTNAME = 'localhost'
DEFAULT_PORT = 53
DEFAULT_TIMEOUT = 8


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 DNS lookup time in milliseconds. '
        'Supports Nagios ranges. '
        'Example: `--critical=500` alerts above 500 ms. '
        'Default: %(default)s',
        dest='CRIT',
        default=None,
    )

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

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

    parser.add_argument(
        '-p',
        '--port',
        help='Port number placed into the addresses the lookup returns. It does not change '
        'the name server that is asked. '
        'Default: %(default)s',
        dest='PORT',
        type=int,
        default=DEFAULT_PORT,
    )

    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(
        '--type',
        help='Connection type to narrow the list of returned addresses.',
        dest='TYPE',
        choices=['udp', 'udp6', 'tcp', 'tcp6'],
    )

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for the DNS lookup time in milliseconds. '
        'Supports Nagios ranges. '
        'Example: `--warning=100` alerts above 100 ms. '
        'Default: %(default)s',
        dest='WARN',
        default=None,
    )

    args, _ = parser.parse_known_args()
    return args


# getaddrinfo() on Windows says only "getaddrinfo failed" and leaves the reason to the
# WinSock error code; Linux spells it out ("Name or service not known").
WINSOCK_REASONS = {
    11001: 'host not found',
    11002: 'temporary failure, the name server did not answer',
    11003: 'the name server refused the query',
    11004: 'no address of the requested type',
}

# what --type narrows getaddrinfo() to: family, socket type, protocol
TYPES = {
    'tcp': (lib.net.AF_INET, lib.net.SOCK_TCP, lib.net.PROTO_TCP),
    'tcp6': (lib.net.AF_INET6, lib.net.SOCK_TCP, lib.net.PROTO_TCP),
    'udp': (lib.net.AF_INET, lib.net.SOCK_UDP, lib.net.PROTO_UDP),
    'udp6': (lib.net.AF_INET6, lib.net.SOCK_UDP, lib.net.PROTO_UDP),
}


def lookup(args):
    """Resolve --hostname like the operating system does, within --timeout.

    Returns `(runtime_ms, result)`; `result` is the list of 5-tuples getaddrinfo()
    returns, the exception it raised, or None on a timeout.
    """
    family, socktype, proto = TYPES.get(args.TYPE, (0, 0, 0))
    start_time = time.time()
    try:
        result = lib.net.getaddrinfo(
            args.HOSTNAME,
            args.PORT,
            family,
            socktype,
            proto,
            timeout=args.TIMEOUT,
        )
    except socket.timeout:
        result = None
    except OSError as e:
        result = e
    runtime = (time.time() - start_time) * 1000  # we get seconds and want ms
    return runtime, result


def replay(test):
    """Return what lookup() would, from a fixture captured on a real host.

    The fixture is JSON: `{"runtime_ms": 1.2, "result": [["AF_INET6", "SOCK_STREAM",
    6, "", ["::1", 53, 0, 0]], ...]}`, `{"runtime_ms": 3.4, "error": {"type":
    "gaierror", "errno": -2, "strerror": "Name or service not known"}}` or
    `{"runtime_ms": 8000.0, "timeout": true}`. Family and socket type are stored by
    name, because their numbers differ between Linux and Windows; a socket type without
    a name (0 on Windows) is stored as the number.
    """
    stdout, _, _ = lib.lftest.test(test)
    data = json.loads(stdout)
    if data.get('timeout'):
        return data['runtime_ms'], None
    if 'error' in data:
        error = data['error']
        return data['runtime_ms'], getattr(socket, error['type'])(
            error['errno'], error['strerror']
        )

    def constant(value):
        # a name such as AF_INET6, or a plain number (Windows returns socket type 0)
        return getattr(socket, value) if isinstance(value, str) else value

    return data['runtime_ms'], [
        (constant(af), constant(kind), proto, canonname, tuple(sa))
        for af, kind, proto, canonname, sa in data['result']
    ]


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)

    # https://docs.python.org/2/library/socket.html#socket.getaddrinfo
    # socket.getaddrinfo(host, port[, family[, socktype[, proto[, flags]]]])

    # The function returns a list of 5-tuples with the following structure:
    # (family, socktype, proto, canonname, sockaddr)
    # family, socktype, proto are all integers
    # sockaddr is a tuple describing a socket address
    #   (address, port) 2-tuple for AF_INET
    #   (address, port, flow info, scope id) for AF_INET6
    # Example results:
    # [(10, 1, 6, '', ('2606:2800:220:1:248:1893:25c8:1946',
    #   53, 0, 0)), ...]
    # [(2, 1, 6, '', ('172.217.168.67', 53)), ...]

    # a name that cannot be encoded for DNS, such as one with an empty label, is a wrong
    # parameter, not a failed lookup
    try:
        # an empty name or one with a blank is no host name either; the resolver
        # answers the one with an error and waits out the timeout on the other
        if not args.HOSTNAME.strip() or any(c.isspace() for c in args.HOSTNAME):
            raise UnicodeError
        args.HOSTNAME.encode('idna')
    except UnicodeError:
        lib.base.cu(
            f'Invalid --hostname "{args.HOSTNAME}": not a valid host name, for example'
            ' because it is empty, holds a blank, has an empty label ("..") or a label'
            ' longer than 63 characters.',
            traceback=False,
        )

    if not 0 <= args.PORT <= 65535:
        lib.base.cu(
            f'Invalid --port {args.PORT}: a port is a number from 0 to 65535.',
            traceback=False,
        )

    # fetch data
    target = f'{args.HOSTNAME}:{args.PORT}' + (f'/{args.TYPE}' if args.TYPE else '')
    if args.TEST is None:
        runtime, result = lookup(args)
    else:
        runtime, result = replay(args.TEST)

    # init some vars
    addresses = []

    # analyze data
    if result is None:
        lib.base.oao(
            f'Timeout after {args.TIMEOUT}s while looking up {target}.',
            STATE_WARN,
            always_ok=args.ALWAYS_OK,
        )
    if isinstance(result, (socket.gaierror, socket.herror)):
        reason = WINSOCK_REASONS.get(result.errno)
        lib.base.oao(
            f'Unable to resolve {target}: '
            + (f'{reason} ({result}).' if reason else f'{result}.'),
            STATE_WARN,
            always_ok=args.ALWAYS_OK,
        )
    if isinstance(result, OSError):
        lib.base.oao(
            f'Socket error "{result}" for {target}.',
            STATE_WARN,
            always_ok=args.ALWAYS_OK,
        )
    if not result:
        lib.base.oao(
            f'Lookup for {target} returned no address.',
            STATE_WARN,
            always_ok=args.ALWAYS_OK,
        )
    state = lib.base.get_state(runtime, args.WARN, args.CRIT, _operator='range')

    # build the message
    # Without --type, Linux returns every address three times (TCP, UDP, raw) and
    # Windows once; name each address once, as Windows does.
    for af, _, proto, _, sa in result:
        if not args.TYPE:
            proto = lib.net.PROTO_IP
        address = f'{sa[0]} ({lib.net.PROTOSTR[proto]}{lib.net.FAMILIYSTR[af]}:{sa[1]})'
        if address not in addresses:
            addresses.append(address)
    # the lookup time is what the thresholds judge, so it goes next to the verdict
    msg = (
        f'Lookup for {args.HOSTNAME} returns {", ".join(addresses)}'
        f' in {round(runtime, 1)}ms{lib.base.state2str(state, prefix=" ")}'
    )
    perfdata = lib.base.get_perfdata(
        'time',
        round(runtime, 3),
        uom='ms',
        warn=args.WARN,
        crit=args.CRIT,
        _min=0,
    )

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