#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (C) 2019 tribe29 GmbH - License: GNU General Public License v2
# This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
# conditions defined in the file COPYING, which is part of this source code package.

# This check does a traceroute to the specified target host
# (usually $HOSTADDRESS$ itself) and checks which route(s) are
# being taken. That way you can check if your preferred or
# some alternative route in in place.
# traceroute is expected to be in the search path and installed
# with SUID root bit.

# Example output from traceroute -n
# traceroute to www.google.de (173.194.44.55), 30 hops max, 60 byte packets
#  1  10.10.11.4  0.419 ms  0.444 ms  0.518 ms
#  2  33.117.16.28  14.359 ms  14.371 ms  14.434 ms
#  3  112.18.7.119  14.750 ms  14.765 ms  19.530 ms
#  4  184.50.190.61  17.844 ms  17.865 ms  17.862 ms
#  5  67.249.94.88  24.285 ms  78.527 ms  26.834 ms
#  6  209.85.240.99  27.910 ms  27.420 ms  27.442 ms
#  7  173.194.44.55  26.583 ms  20.410 ms  23.142 ms

# Output without -n option:
# traceroute to www.google.de (173.194.44.56), 30 hops max, 60 byte packets
#  1  fritz.box (10.10.11.4)  0.570 ms  0.606 ms  0.677 ms
#  2  foo-bar.x-online.net (33.117.16.28)  14.566 ms  14.580 ms  14.658 ms
#  3  xu-2-3-0.rt-inxs-1.x-online.net (112.13.6.109)  18.214 ms  18.228 ms  18.221 ms
#  4  * * *
#  5  66.249.94.88 (66.249.94.88)  24.481 ms  24.498 ms  24.271 ms
#  6  209.85.240.99 (209.85.240.99)  27.628 ms  21.605 ms  21.943 ms
#  7  muc03s08-in-f24.1e100.net (173.194.44.56)  21.277 ms  22.236 ms  22.192 ms

# Example output for IPv6
# traceroute to ipv6.google.com (2404:6800:4004:80e::200e), 30 hops max, 80 byte packets
#  1  2001:2e8:665:0:2:2:0:1 (2001:2e8:665:0:2:2:0:1)  0.082 ms  0.046 ms  0.044 ms
#  2  2001:2e8:22:204::2 (2001:2e8:22:204::2)  0.893 ms  0.881 ms  0.961 ms
#  3  * 2001:4860:0:1::1abd (2001:4860:0:1::1abd)  225.189 ms *
#  4  2001:4860:0:1003::1 (2001:4860:0:1003::1)  3.052 ms  2.820 ms 2001:4860:0:1002::1 (2001:4860:0:1002::1)  1.501 ms
#  5  nrt13s48-in-x0e.1e100.net (2404:6800:4004:80e::200e)  1.910 ms  1.828 ms  1.753 ms

# It is also possible that for one hop several different answers appear:
# 11 xe-0-0-1-0.co2-96c-1b.ntwk.msn.net (204.152.141.11)  174.185 ms xe-10-0-2-0.co1-96c-1a.ntwk.msn.net (207.46.40.94)  174.279 ms xe-0-0-1-0.co2-96c-1b.ntwk.msn.net (204.152.141.11)  174.444 ms

# if DNS fails then it looks like this:
#  5  66.249.94.88 (66.249.94.88)  24.481 ms  24.498 ms  24.271 ms
#  6  209.85.240.99 (209.85.240.99)  27.628 ms  21.605 ms  21.943 ms

import ast
import getopt
import ipaddress
import os
import subprocess
import sys


class MissingValueError(Exception):
    pass


class ProtocolVersionError(Exception):
    pass


class ExecutionError(Exception):
    pass


def parse_exception(exc):
    exc = str(exc)
    if exc[0] == "{":
        exc = "%d - %s" % list(ast.literal_eval(exc).values())[0]
    return str(exc)


def output_check_result(s, perfdata):
    if perfdata:
        perfdata_output_entries = ["%s=%s" % (p[0], ";".join(map(str, p[1:]))) for p in perfdata]
        s += " | %s" % " ".join(perfdata_output_entries)
    sys.stdout.write("%s\n" % s)


def option_to_state(c):
    return {"w": 1, "c": 2}[c.lower()]


def _execute_traceroute(target, nodns, method, address_family):
    cmd = ["traceroute"]
    if nodns:
        cmd.append("-n")
    if method:
        cmd.append(method)
    if address_family:
        cmd.append(address_family)
    cmd.append(target)
    completed_process = subprocess.run(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        encoding="utf8",
        check=False,
    )
    if completed_process.returncode:
        raise ExecutionError("UNKNOWN - " + completed_process.stderr.replace("\n", " "))
    return completed_process.stdout


def check_traceroute(lines, routes):
    # find all visited routers
    routers = set([])
    hops = len(lines[1:])
    for line in lines[1:]:
        parts = line.strip().split()
        for part in parts:
            try:
                part = part.lstrip("(").rstrip(",").rstrip(")")
                ipaddress.ip_interface(part)
                routers.add(part)
            except ValueError:
                pass

    state = 0
    bad_routers = []
    missing_routers = []
    for option, route in routes:
        s = option_to_state(option)
        if option.islower() and route in routers:
            state = max(state, s)
            bad_routers.append("%s(%s)" % (route, "!" * s))
        elif option.isupper() and route not in routers:
            state = max(state, s)
            missing_routers.append("%s(%s)" % (route, "!" * s))

    info_text = "%d hops, missing routers: %s, bad routers: %s" % (
        hops,
        missing_routers and ", ".join(missing_routers) or "none",
        bad_routers and ", ".join(bad_routers) or "none",
    )
    perfdata = [("hops", hops)]
    return state, info_text, perfdata


def validate_ip_version(address_arg, ip_version_arg):
    # ipv6 address can have an appended interface index/name: 'fe80::%{interface}'
    try:
        ip_address_version = ipaddress.ip_interface(address_arg.split("%")[0]).ip.version
    except ValueError:
        # May be a host or DNS name, don't execute the validation in this case.
        # check_traceroute will perform the name resolution for us.
        return

    if not ip_address_version == ip_version_arg:
        raise ProtocolVersionError(
            'IP protocol version "%s" not the same as the IP address version "%s".'
            % (ip_version_arg, ip_address_version)
        )


def usage():
    sys.stdout.write(
        """check_traceroute -{c|w|C|W} ROUTE  [-{o|c|w|O|C|W} ROUTE...] TARGET

Check by which routes TARGET is being reached. Each possible route is being
prefixed with a state option:

 -w Make outcome WARN if that route is present
 -W Make outcome WARN if that route is missing
 -c Make outcome CRIT if that route is present
 -C Make outcome CRIT if that route is missing

Other options:

 -h, --help     show this help and exit
 --debug        show Python exceptions verbosely
 -n             disable reverse DNS lookups
 -I             Use ICMP ECHO for probes
 -T             Use TCP SYN for probes
 -4             Use IPv4
 -6             Use IPv6

"""
    )


def main(args=None):
    if args is None:
        args = sys.argv[1:]

    os.unsetenv("LANG")

    opt_verbose = 0
    opt_debug = False
    opt_nodns = False
    opt_method = None
    opt_address_family = None

    short_options = "hw:W:c:C:nTI46"
    long_options = [
        "verbose",
        "help",
        "debug",
    ]

    route_params = []

    try:
        opts, args = getopt.getopt(args, short_options, long_options)

        if len(args) < 1:
            raise MissingValueError("Please specify the target destination.")

        target_address = args[0]

        # first parse modifers
        for o, a in opts:
            if o in ["-v", "--verbose"]:
                opt_verbose += 1
            elif o in ["-d", "--debug"]:
                opt_debug = True
            elif o in ["-w", "-W", "-c", "-C"]:
                route_params.append((o[1], a))
            elif o == "-n":
                opt_nodns = True
            elif o in ["-T", "-I"]:
                opt_method = o
            elif o in ["-4", "-6"]:
                if opt_address_family:
                    raise ProtocolVersionError("Cannot use both IPv4 and IPv6")
                validate_ip_version(target_address, int(o.lstrip("-")))
                opt_address_family = o

        # now handle action options
        for o, a in opts:
            if o in ["-h", "--help"]:
                usage()
                sys.exit(0)

        sto = _execute_traceroute(target_address, opt_nodns, opt_method, opt_address_family)
        status, output, perfdata = check_traceroute(sto.split("\n"), route_params)
        info_text = output.strip() + "\n%s" % sto
        return status, info_text, perfdata

    except ExecutionError as e:
        return 3, str(e), None

    except MissingValueError as e:
        return 3, str(e), None

    except ProtocolVersionError as e:
        return 3, str(e), None

    except Exception as e:
        if opt_debug:
            raise
        return 2, "Unhandled exception: %s" % parse_exception(e), None


if __name__ == "__main__":
    exitcode, info, perf = main()
    output_check_result(info, perf)
    sys.exit(exitcode)
