diff --git a/jc/cli.py b/jc/cli.py index 34701399..bc0af389 100644 --- a/jc/cli.py +++ b/jc/cli.py @@ -77,6 +77,7 @@ parsers = [ 'systemctl-ls', 'systemctl-luf', 'timedatectl', + 'traceroute', 'uname', 'uptime', 'w', diff --git a/jc/parsers/traceroute.py b/jc/parsers/traceroute.py new file mode 100644 index 00000000..e55e924c --- /dev/null +++ b/jc/parsers/traceroute.py @@ -0,0 +1,295 @@ +"""jc - JSON CLI output utility traceroute Parser + +Usage: + + specify --traceroute as the first argument if the piped input is coming from traceroute + +Compatibility: + + 'linux', 'darwin', 'freebsd' + +Examples: + + $ traceroute | jc --traceroute -p + [] + + $ traceroute | jc --traceroute -p -r + [] +""" +import re +from decimal import Decimal +import jc.utils + + +class info(): + version = '1.0' + description = 'traceroute command parser' + author = 'Kelly Brazil' + author_email = 'kellyjonbrazil@gmail.com' + details = 'Using the tr library by Luis Benitez at https://github.com/lbenitez000/trparse' + + # compatible options: linux, darwin, cygwin, win32, aix, freebsd + compatible = ['linux', 'darwin', 'freebsd'] + magic_commands = ['traceroute', 'traceroute6'] + + +__version__ = info.version + + +""" +Copyright (C) 2015 Luis Benitez + +Parses the output of a traceroute execution into an AST (Abstract Syntax Tree). +""" + +RE_HEADER = re.compile(r'(\S+)\s+\((?:(\d+\.\d+\.\d+\.\d+)|([0-9a-fA-F:]+))\)') + +RE_PROBE_NAME_IP = re.compile(r'(\S+)\s+\((?:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|([0-9a-fA-F:]+))\)+') +RE_PROBE_ANNOTATION = re.compile(r'^(!\w*)$') +RE_PROBE_TIMEOUT = re.compile(r'^(\*)$') + +RE_HOP_INDEX = re.compile(r'^\s*(\d+)\s+') +RE_FIRST_HOP = re.compile(r'^\s*(\d+)\s+(.+)') +RE_HOP = re.compile(r'^\s*(\d+)?\s+(.+)$') +RE_PROBE_ASN = re.compile(r'\[AS(\d+)\]') +RE_PROBE_RTT_ANNOTATION = re.compile(r'(?:(\d+(?:\.?\d+)?)\s+ms|(\s+\*\s+))\s*(!\S*)?') + + +class Traceroute(object): + """ + Abstraction of a traceroute result. + """ + + def __init__(self, dest_name, dest_ip): + self.dest_name = dest_name + self.dest_ip = dest_ip + self.hops = [] + + def add_hop(self, hop): + self.hops.append(hop) + + def __str__(self): + text = "Traceroute for %s (%s)\n\n" % (self.dest_name, self.dest_ip) + for hop in self.hops: + text += str(hop) + return text + + +class Hop(object): + """ + Abstraction of a hop in a traceroute. + """ + + def __init__(self, idx): + self.idx = idx # Hop count, starting at 1 + self.probes = [] # Series of Probe instances + + def add_probe(self, probe): + """Adds a Probe instance to this hop's results.""" + if self.probes: + probe_last = self.probes[-1] + if not probe.ip: + probe.ip = probe_last.ip + probe.name = probe_last.name + self.probes.append(probe) + + def __str__(self): + text = "{:>3d} ".format(self.idx) + text_len = len(text) + for n, probe in enumerate(self.probes): + text_probe = str(probe) + if n: + text += (text_len * " ") + text_probe + else: + text += text_probe + text += "\n" + return text + + +class Probe(object): + """ + Abstraction of a probe in a traceroute. + """ + + def __init__(self, name=None, ip=None, asn=None, rtt=None, annotation=None): + self.name = name + self.ip = ip + self.asn = asn # Autonomous System number + self.rtt = rtt # RTT in ms + self.annotation = annotation # Annotation, such as !H, !N, !X, etc + + def __str__(self): + text = "" + if self.asn is not None: + text += "[AS{:d}] ".format(self.asn) + if self.rtt: + text += "{:s} ({:s}) {:1.3f} ms".format(self.name, self.ip, self.rtt) + else: + text = "*" + if self.annotation: + text += " {:s}".format(self.annotation) + text += "\n" + return text + + +def loads(data): + """Parser entry point. Parses the output of a traceroute execution""" + + lines = data.splitlines() + + # Get headers + match_dest = RE_HEADER.search(lines[0]) + dest_name = match_dest.group(1) + dest_ip = match_dest.group(2) + + # The Traceroute node is the root of the tree + traceroute = Traceroute(dest_name, dest_ip) + + # Parse the remaining lines, they should be only hops/probes + for line in lines[1:]: + # Skip empty lines + if not line: + continue + + hop_match = RE_HOP.match(line) + + if hop_match.group(1): + hop_index = int(hop_match.group(1)) + else: + hop_index = None + + if hop_index is not None: + hop = Hop(hop_index) + traceroute.add_hop(hop) + + hop_string = hop_match.group(2) + + probe_asn_match = RE_PROBE_ASN.search(hop_string) + if probe_asn_match: + probe_asn = int(probe_asn_match.group(1)) + else: + probe_asn = None + + probe_name_ip_match = RE_PROBE_NAME_IP.search(hop_string) + if probe_name_ip_match: + probe_name = probe_name_ip_match.group(1) + probe_ip = probe_name_ip_match.group(2) or probe_name_ip_match.group(3) + else: + probe_name = None + probe_ip = None + + probe_rtt_annotations = RE_PROBE_RTT_ANNOTATION.findall(hop_string) + + for probe_rtt_annotation in probe_rtt_annotations: + if probe_rtt_annotation[0]: + probe_rtt = Decimal(probe_rtt_annotation[0]) + elif probe_rtt_annotation[1]: + probe_rtt = None + else: + message = "Expected probe RTT or *. Got: '{}'".format(probe_rtt_annotation[0]) + raise Exception(message) + + probe_annotation = probe_rtt_annotation[2] or None + + probe = Probe( + name=probe_name, + ip=probe_ip, + asn=probe_asn, + rtt=probe_rtt, + annotation=probe_annotation + ) + hop.add_probe(probe) + + return traceroute + + +def load(data): + return loads(data.read()) + + +class ParseError(Exception): + pass + + + + + + +def process(proc_data): + """ + Final processing to conform to the schema. + + Parameters: + + proc_data: (dictionary) raw structured data to process + + Returns: + + List of dictionaries. Structured data with the following schema: + + [ + { + "foo": string, + "bar": boolean, + "baz": integer + } + ] + """ + + # rebuild output for added semantic information + return proc_data + + +def parse(data, raw=False, quiet=False): + """ + Main text parsing function + + Parameters: + + data: (string) text data to parse + raw: (boolean) output preprocessed JSON if True + quiet: (boolean) suppress warning messages if True + + Returns: + + List of dictionaries. Raw or processed structured data. + """ + if not quiet: + jc.utils.compatibility(__name__, info.compatible) + + raw_output = {} + + if jc.utils.has_data(data): + + tr = loads(data) + hops = tr.hops + hops_list = [] + + if hops: + for hop in hops: + hop_obj = {} + hop_obj['id'] = hop.idx + probe_list = [] + if hop.probes: + for probe in hop.probes: + probe_obj = { + 'annotation': probe.annotation, + 'asn': probe.asn, + 'ip': probe.ip, + 'name': probe.name, + 'rtt': None if probe.rtt is None else float(probe.rtt) + } + probe_list.append(probe_obj) + hop_obj['probes'] = probe_list + hops_list.append(hop_obj) + + raw_output = { + 'destination_ip': tr.dest_ip, + 'destination_name': tr.dest_name, + 'hops': hops_list + } + + if raw: + return raw_output + else: + return process(raw_output) diff --git a/tests/fixtures/osx-10.14.6/traceroute.out b/tests/fixtures/osx-10.14.6/traceroute.out new file mode 100644 index 00000000..372c309a --- /dev/null +++ b/tests/fixtures/osx-10.14.6/traceroute.out @@ -0,0 +1,11 @@ +traceroute to 8.8.8.8 (8.8.8.8), 64 hops max, 52 byte packets + 1 dsldevice (192.168.1.254) 12.070 ms 4.328 ms 4.167 ms + 2 76-220-24-1.lightspeed.sntcca.sbcglobal.net (76.220.24.1) 20.595 ms 26.130 ms 28.555 ms + 3 * * * + 4 12.122.149.186 (12.122.149.186) 149.663 ms 27.761 ms 160.709 ms + 5 sffca22crs.ip.att.net (12.122.3.70) 27.131 ms 160.459 ms 32.274 ms + 6 12.122.163.61 (12.122.163.61) 143.143 ms 27.034 ms 152.676 ms + 7 12.255.10.234 (12.255.10.234) 24.912 ms 23.802 ms 157.338 ms + 8 * * * + 9 dns.google (8.8.8.8) 30.840 ms 22.503 ms 23.538 ms +