#!/usr/bin/env python3
"""
Tor Contact Info Parser - A tool/Python Class for parsing Tor ContactInfo Information Sharing v2 specification contacts
Written by Eran Sandler  (https://twitter.com/erans) (C) 2018

Turned into a proper command-line tool with sub-commands and flags by @Someguy123 at Privex Inc. (C) 2021
(https://www.privex.io) (https://github.com/PrivexInc)

This is a parser for the Tor ContactInfo Information Sharing Specification v2 (https://nusenu.github.io/ContactInfo-Information-Sharing-Specification/).

The parser can parse the ContactInfo field of Tor relays based on the specification.

Official Repo: https://github.com/erans/torcontactinfoparser
Privex Fork: https://github.com/Privex/torcontactinfoparser

Released under the MIT License.
"""
import argparse
import os
import re
import sys
import json
import requests
import textwrap
try:
    from rich import print as rprint
    HAS_RICH = True
except ImportError:
    def rprint(value='', *args, **kwargs):
        if value not in [None, False, True] and isinstance(value, (dict, list, set, tuple)):
            value = json.dumps(value, indent=4)
        return print(value, *args, **kwargs)
    # rprint = print
    HAS_RICH = False

global LOG
import logging
import warnings

warnings.filterwarnings('ignore')
LOG = logging.getLogger()

class TorContactInfoParser(object):
    email_regex = "^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$"

    def _parse_string_value(self, value, min_length, max_length, valid_chars, raise_exception=False, field_name=None, deobfuscate_email=False):
        value_length = len(value)
        if value_length < min_length:
            if raise_exception:
                raise ValueError("value of field '{0}' is too short".format(field_name))
            return None

        if value_length > max_length:
            if raise_exception:
                raise ValueError("value of field '{0}' is too long".format(field_name))
            return None

        if valid_chars != "*":
            m = re.search(valid_chars, value)
            if not m:
                if raise_exception:
                    raise ValueError("value of field '{0}' doesn't match valid chars restrictions".format(field_name))
                else:
                    return None

        return value

    def _parse_email_value(self, value, field_name, raise_exception, deobfuscate_email):
        if value:
            v = value.replace("[]", "@")
            if re.search(self.email_regex, v):
                if not deobfuscate_email:
                    return v.replace("@", "[]")

                return v

        return None

    _supported_fields_parsers = {
        "email" : {
            "fn": _parse_email_value,
            "args": {}
        },
        "url" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 4,
                "max_length" : 399,
                "valid_chars" : "[_%/:a-zA-Z0-9.-]+"
            }
        },
        "proof" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 7,
                "max_length" : 7,
                "valid_chars" : "[adinrsu-]+"
            }
        },
        "ciissversion" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 1,
                "max_length" : 1,
                "valid_chars" : "[12]+"
            }
        },
        "pgp" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 40,
                "max_length" : 40,
                "valid_chars" : "[a-zA-Z0-9]+"
            }
        },
        "abuse" : {
            "fn": _parse_email_value,
            "args": {}
        },
        "keybase" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 50,
                "valid_chars" : "[a-zA-Z0-9]+"
            }
        },
        "twitter" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 1,
                "max_length" : 15,
                "valid_chars" : "[a-zA-Z0-9_]+"
            }
        },
        "mastodon" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 254,
                "valid_chars" : "*"
            }
        },
        "matrix" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 254,
                "valid_chars" : "*"
            }
        },
        "xmpp" : {
            "fn": _parse_email_value,
            "args": {}
        },
        "otr3" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 40,
                "max_length" : 40,
                "valid_chars" : "[a-z0-9]+"
            }
        },
        "hoster" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 254,
                "valid_chars" : "[a-zA-Z0-9.-]+"
            }
        },
        "cost" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 13,
                "valid_chars" : "[A-Z0-9.]+"
            }
        },
        "uplinkbw" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 7,
                "valid_chars" : "[0-9]+"
            }
        },
        "trafficacct" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 9,
                "valid_chars" : "[unmetrd0-9]+"
            }
        },
        "memory" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 10,
                "valid_chars" : "[0-9]+"
            }
        },
        "cpu" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 50,
                "valid_chars" : "[a-zA-Z0-9_-]+"
            }
        },
        "virtualization" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 15,
                "valid_chars" : "[a-z-]+"
            }
        },
        "donationurl" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 254,
                "valid_chars" : "*"
            }
        },
        "btc" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 26,
                "max_length" : 99,
                "valid_chars" : "[a-zA-Z0-9]+"
            }
        },
        "zec" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 95,
                "valid_chars" : "[a-zA-Z0-9]+"
            }
        },
        "xmr" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 99,
                "valid_chars" : "[a-zA-Z0-9]+"
            }
        },
        "offlinemasterkey" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 1,
                "max_length" : 1,
                "valid_chars" : "[yn]"
            }
        },
        "signingkeylifetime" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 6,
                "valid_chars" : "[0-9]+"
            }
        },
        "sandbox" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 1,
                "max_length" : 2,
                "valid_chars" : "[yn]"
            }
        },
        "os" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 20,
                "valid_chars" : "[A-Za-z0-9/.]+"
            }
        },
        "tls" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 0,
                "max_length" : 14,
                "valid_chars" : "[a-z]+"
            }
        },
        "aesni" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 1,
                "max_length" : 1,
                "valid_chars" : "[yn]"
            }
        },
        "autoupdate" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 1,
                "max_length" : 1,
                "valid_chars" : "[yn]"
            }
        },
        "confmgmt" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 1,
                "max_length" : 15,
                "valid_chars" : "[a-zA-Z-]"
            }
        },
        "dnslocation" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 5,
                "max_length" : 100,
                "valid_chars" : "[a-z,]"
            }
        },
        "dnsqname" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 1,
                "max_length" : 1,
                "valid_chars" : "[yn]"
            }
        },
        "dnssec" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 1,
                "max_length" : 1,
                "valid_chars" : "[yn]"
            }
        },
        "dnslocalrootzone" : {
            "fn" : _parse_string_value,
            "args" : {
                "min_length" : 1,
                "max_length" : 1,
                "valid_chars" : "[yn]"
            }
        }
    }

    def __init__(self):
        pass

    def parse(self, value: str, raise_exception_on_invalid_value=False, deobfuscate_email=True) -> dict:
        # the ciissversion field is mandatory
        if not 'ciissversion:' in value:
            return None

        result = {}
        parts = value.split(" ")
        for p in parts:
            field_parts = p.split(":", 1)
            if len(field_parts) <= 1:
                continue
            name, data = field_parts
            if name in self._supported_fields_parsers:
                field_parser = self._supported_fields_parsers[name]
                if field_parser is None:
                    result[name] = data
                    continue
                if callable(field_parser):
                    value = field_parser(self, data)
                else:
                    field_parser["args"]["field_name"] = name
                    field_parser["args"]["value"] = data
                    field_parser["args"]["raise_exception"] = raise_exception_on_invalid_value
                    field_parser["args"]["deobfuscate_email"] = deobfuscate_email

                    value = field_parser["fn"](self, **field_parser["args"])

                if not result.get(name, None):
                    result[name] = value

        return result

def cmd_parse(opts: argparse.Namespace):
    """
    ArgParser function for parsing a single ContactInfo string, and outputting it as JSON (or python-style dict's)
    """

    if opts.contact is None or len(opts.contact) == 0 or opts.contact[0] == '-':
        contact = sys.stdin.read()
    else:
        contact = ' '.join(opts.contact).strip()

    tparser = TorContactInfoParser()
    res = tparser.parse(contact)
    if not opts.pretty:
        return print(json.dumps(res))
    if opts.json:
        res = json.dumps(res, indent=4) if opts.pretty else json.dumps(res)
    # if not HAS_RICH: res = json.dumps(res, indent=4)
    rprint(res)

def vsetup_logging(log_level, logfile='', stream=sys.stderr):
    global LOG
    add = True

    try:
        if 'COLOREDLOGS_LEVEL_STYLES' not in os.environ:
            os.environ['COLOREDLOGS_LEVEL_STYLES'] = 'spam=22;debug=28;verbose=34;notice=220;warning=202;success=118,bold;error=124;critical=background=red'
        # https://pypi.org/project/coloredlogs/
        import coloredlogs
    except ImportError:
        coloredlogs = False

    logging._defaultFormatter = logging.Formatter(datefmt='%m-%d %H:%M:%S')
    logging._defaultFormatter.default_time_format = '%m-%d %H:%M:%S'
    logging._defaultFormatter.default_msec_format = ''

    kwargs = dict(level=log_level,
                  force=True,
                  format='%(levelname)s %(message)s')

    if logfile:
        add = logfile.startswith('+')
        sub = logfile.startswith('-')
        if add or sub:
            logfile = logfile[1:]
        kwargs['filename'] = logfile

    if coloredlogs:
        # https://pypi.org/project/coloredlogs/
        aKw = dict(level=log_level,
                   logger=LOG,
                   stream=stream,
                   fmt='%(levelname)s %(message)s'
                   )
        coloredlogs.install(**aKw)
        if logfile:
            oHandler = logging.FileHandler(logfile)
            LOG.addHandler(oHandler)
        LOG.info(f"CSetting log_level to {log_level} {stream}")
    else:
        logging.basicConfig(**kwargs)
        if add and logfile:
            oHandler = logging.StreamHandler(stream)
            LOG.addHandler(oHandler)
        LOG.info(f"SSetting log_level to {log_level!s}")

def cmd_scan(opts: argparse.Namespace, adata=None):
    """
    ArgParser function for scanning all ContactInfo strings from ``https://onionoo.torproject.org/details`` ,
    and outputting each one as a Python-style Dict, or JSON.
    """
    parser = TorContactInfoParser()
    surl = "https://onionoo.torproject.org/details"

    if not adata:
        LOG.info(f"Getting relays from {surl}")
        jdata = requests.get(surl)
        try:
            adata = jdata.json()
        except Exception as e:
            # simplejson.errors.JSONDecodeError
            LOG.exception(f"JSON error {e}")
            return
        elts = adata["relays"]
    else:
        elts = json.loads(adata)['relays']

    if not elts:
        LOG.warn(f"NO relays - are we connected?")
        return
    LOG.info(f"{len(elts)} relays")
    for relay in elts:
        if 'fingerprint' not in relay.keys():
            LOG.warn(f"fingerprint not in relay for {relay}")
            continue
        fp = relay['fingerprint']
        verified_host_names = relay.get('verified_host_names', [])
        contact = relay.get("contact", None)
        if not contact:
            LOG.warn(f"No contact for {fp} {verified_host_names}")
            continue
        if 'ciissversion' not in contact:
            LOG.debug(f"No ciissversion in contact in {fp}")
            continue
        LOG.debug(f"parsing {fp}")
        result = parser.parse(contact, False)
        if not result:
            LOG.warn(f"No result for {contact} in {fp}")
            continue
        if len(result) > 0:
            if opts.json: result = json.dumps(result, indent=4) if opts.pretty else json.dumps(result)
            if opts.pretty:
                rprint(result)
            else:
                print(result)

ETC_DIR = '/etc/tor/yaml'
def oparser():
    cparser = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
        description=textwrap.dedent(f"""
    Examples:

        # 'scan' is the original behaviour of this script. It iterates over the data
        # from https://onionoo.torproject.org/details , parses each contact, and prints it as Python dict-style JSON.
        {sys.argv[0]} scan

        # Same as previous. With no arguments, it's equivalent to running 'scan'.
        {sys.argv[0]}

        # If you pass '-p' after scan, it will enable pretty printing. For best pretty printing,
        # make sure you have 'rich' installed from pypi.
        {sys.argv[0]} scan -p

        # If you need real JSON with double quotes, rather than Python dict-style JSON, you can
        # use the '-j' flag to enable "real JSON" mode (you can combine with '-p' if you want pretty printed real json)
        {sys.argv[0]} scan -j

        # Using 'parse', you can parse an arbitrary ContactInfo string, and it will output the parsed result
        # with pretty printing by default.

        {sys.argv[0]} parse "contact Privex Inc. email:noc[]privex.io url:https://www.privex.io " \\
                "proof:uri-rsa pgp:288DD1632F6E8951 keybase:privexinc twitter:PrivexInc hoster:www.privex.io " \\
                "uplinkbw:500 memory:4096 virtualization:kvm btc:bc1qpst9uscvd8rpjjhzz9rau3trylh6e0wh76qrlhw3q9nj89ua728sn3t6a2 " \\
                "xmr:89tukP3wfpH4FZAmC1D2GfArWwfPTz8Ap46NZc54Vyhy9YxEUYoFQ7HGQ74LrCMQTD3zxvwM1ewmGjH9WVmeffwR72m1Pps"

            {{
                'email': 'noc@privex.io',
                'url': 'https://www.privex.io',
                'proof': 'uri-rsa',
                'pgp': None,
                'keybase': 'privexinc',
                'twitter': 'PrivexInc',
                'hoster': 'www.privex.io',
                'uplinkbw': '500',
                'memory': '4096',
                'virtualization': 'kvm',
                'btc': 'bc1qpst9uscvd8rpjjhzz9rau3trylh6e0wh76qrlhw3q9nj89ua728sn3t6a2',
                'xmr': '89tukP3wfpH4FZAmC1D2GfArWwfPTz8Ap46NZc54Vyhy9YxEUYoFQ7HGQ74LrCMQTD3zxvwM1ewmGjH9WVmeffwR72m1Pps'
            }}

        # You can also pipe a contact string into 'parse', and it will work just the same.

        echo "Privex Inc. email:noc[]privex.io url:https://www.privex.io proof:uri-rsa pgp:288DD1632F6E8951 keybase:privexinc twitter:PrivexInc" | {sys.argv[0]} parse
        {{'email': 'noc@privex.io', 'url': 'https://www.privex.io', 'proof': 'uri-rsa', 'pgp': None, 'keybase': 'privexinc', 'twitter': 'PrivexInc\n'}}

        # If you need real JSON outputted, rather than Python dict-style output, you can pass -j to either 'parse' or 'scan'

        {sys.argv[0]} parse -j "Privex Inc. email:noc[]privex.io url:https://www.privex.io proof:uri-rsa pgp:288DD1632F6E8951 keybase:privexinc twitter:PrivexInc"
            {{
                "email": "noc@privex.io",
                "url": "https://www.privex.io",
                "proof": "uri-rsa",
                "pgp": null,
                "keybase": "privexinc",
                "twitter": "PrivexInc"
            }}

        # You can use '-np' to disable pretty printing for 'parse' - you can combine it with '-j' to get flat, plain JSON.

        {sys.argv[0]} parse -np -j "Privex Inc. email:noc[]privex.io url:https://www.privex.io proof:uri-rsa pgp:288DD1632F6E8951 keybase:privexinc twitter:PrivexInc"
            {{"email": "noc@privex.io", "url": "https://www.privex.io", "proof": "uri-rsa", "pgp": null, "keybase": "privexinc", "twitter": "PrivexInc"}}
    """))
    cparser.set_defaults(func=cmd_scan, json=False, pretty=False)
    subparse = cparser.add_subparsers()
    subparse.required = False
    sp_parse = subparse.add_parser('parse',
                                   help="Parse a single contact string, either as an argument, or piped into stdin")
    sp_parse.add_argument('contact', nargs='*')
    sp_parse.add_argument('-np', '--no-pretty',
                          action='store_false', default=False, dest='pretty',
                          help="Disable pretty printing JSON")
    sp_parse.add_argument('--relays_output', type=str,
                          dest='relays_output',
                          default=os.path.join(ETC_DIR, 'relays.json'),
                          help="Write the download relays in json to a file")
    sp_parse.add_argument('-j', '--json', action='store_true',
                          default=False, dest='json',
                          help="Output real JSON, not Python dict format.")
    sp_parse.set_defaults(func=cmd_parse)

    sp_scan = subparse.add_parser('scan', help="Parse all contacts from https://onionoo.torproject.org/details")
    sp_scan.add_argument('-p', action='store_true', default=False, dest='pretty', help="Enable pretty printing JSON")
    sp_scan.add_argument('-j', '--json', action='store_true', default=False, dest='json', help="Output real JSON, not Python dict format.")

    # sp_scan.set_defaults(func=cmd_scan)

    return cparser

if __name__ == "__main__":
    if os.environ.get('DEBUG', ''):
        log_level = 10
    else:
        log_level = 20
    vsetup_logging(log_level)
    try:
        cparser = oparser()
        opts = cparser.parse_args(sys.argv[1:])
        data = None
        if opts.relays_output and os.path.exists(opts.relays_output):
            data = open(opts.relays_output, 'rt').read()
        cmd_scan(opts, data)
    except (requests.exceptions.ProxyError, Exception,) as e:
        LOG.error(f"{e}")
        i = 0
#    else:
#        args = cparser.parse_args(sys.argv[1:])
#        i = args.func(args)

    sys.exit(i)
