#!/usr/bin/env python3
# arpawalk -- search for ents
#
# Crawls {e164,ip6,in-addr}.arpa searching for empty non-terminals (ENTs),
# allowing rapid enumeration of IPv6 hosts through rDNS.
#
#     arpawalk 1.7.3.e164.arpa
#     arpawalk $(./misc/enumname +372)
#     arpawalk $(./misc/arpaname 10.147.0.0/16)
#
# This is the same method as described here:
# http://web.archive.org/web/20120905125325/http://7bits.nl/blog/2012/03/26/finding-v6-hosts-by-efficiently-mapping-ip6-arpa
#
# (c) 2017-2024 Mantas Mikulėnas <grawity@gmail.com>
# Released under the MIT license <https://spdx.org/licenses/MIT.html>

import argparse
from collections import deque
import dns.name
import dns.resolver
import dns.reversename
import ipaddress
import logging

def get_rdns_domains(addr):
    net = ipaddress.ip_network(addr)
    rev = net.reverse_pointer.split(".")
    if net.version == 6:
        full, bits = divmod(net.prefixlen, 4)
        full += 2
        if bits:
            full += 1
            start = int(rev[-full], 16)
            count = 2 ** (4 - bits)
            for i in range(count):
                rev[-full] = "%x" % (start + i)
                yield ".".join(rev[-full:])
        else:
            yield ".".join(rev[-full:])
    else:
        full, bits = divmod(net.prefixlen, 8)
        full += 2
        if bits:
            full += 1
            start = int(rev[-full], 10)
            count = 2 ** (8 - bits)
            for i in range(count):
                rev[-full] = "%d" % (start + i)
                yield ".".join(rev[-full:])
        else:
            if net.prefixlen == 32:
                rev[0] = "0"
            yield ".".join(rev[-full:])

def fmt_domain(dom):
    dom = dom.lower()
    if len(dom) == 73 and dom.endswith(".ip6.arpa."):
        dom = dns.name.from_text(dom)
        dom = dns.reversename.to_address(dom)
    return dom

def crawl_recursively(domain, card=16, fmt="%x", rtype="PTR",
                              maxdepth=-1, depth=0):
    global stat
    depth = depth or len(domain.strip(".").split("."))
    childs = ["%s.%s" % (fmt % i, domain) for i in range(card)]
    logging.info("querying %r at depth %d", domain, depth)
    stat["requests"] += 1
    try:
        answers = dns.resolver.resolve(domain, rtype)
    except dns.resolver.NoAnswer:
        if depth > maxdepth:
            # Hostmaster accidentally added records with too many labels
            logging.warning("found non-terminal %r beyond expected depth", domain)
        logging.debug("got NoAnswer for %r; descending", domain)
        stat["nonterminals"] += 1
        for child in childs:
            yield from crawl_recursively(child, card, fmt, rtype,
                                                maxdepth, depth+1)
    except dns.resolver.NXDOMAIN:
        logging.debug("got NXDOMAIN for %r; continuing", domain)
        stat["nxdomains"] += 1
    except (dns.resolver.NoNameservers, dns.resolver.LifetimeTimeout):
        logging.warning("got SERVFAIL for %r; returning and continuing", domain)
        stat["baddelegations"] += 1
        yield (domain, None)
    else:
        if depth < maxdepth:
            logging.debug("got answer for %r; returning and descending", domain)
        else:
            logging.debug("got answer for %r; assuming terminal and returning", domain)
        stat["answers"] += 1
        yield from [(domain, j) for j in answers]
        if depth < maxdepth:
            for child in childs:
                yield from crawl_recursively(child, card, fmt, rtype,
                                                    maxdepth, depth+1)

def crawl_iteratively(domain, card=16, fmt="%x", rtype="PTR",
                              maxdepth=-1):
    global stat
    depth = len(domain.strip(".").split("."))
    queue = deque()
    queue += [(depth, domain)]
    while queue:
        (depth, domain) = queue.popleft()
        childs = ["%s.%s" % (fmt % i, domain) for i in range(card)]
        childs = [(depth + 1, d) for d in childs]
        logging.info("querying %r at depth %d [%d more]", domain, depth, len(queue))
        stat["requests"] += 1
        try:
            answers = dns.resolver.resolve(domain, rtype)
        except dns.resolver.NoAnswer:
            if depth > maxdepth:
                # Hostmaster accidentally added records with too many labels
                logging.warning("found non-terminal %r beyond expected depth", domain)
            logging.debug("got NoAnswer for %r; descending", domain)
            stat["nonterminals"] += 1
            queue += childs
        except dns.resolver.NXDOMAIN:
            logging.debug("got NXDOMAIN for %r; continuing", domain)
            stat["nxdomains"] += 1
        except (dns.resolver.NoNameservers, dns.resolver.LifetimeTimeout):
            logging.warning("got SERVFAIL for %r; returning and continuing", domain)
            stat["baddelegations"] += 1
            yield (domain, None)
        else:
            if depth < maxdepth:
                logging.debug("got answer for %r; returning and descending", domain)
                queue += childs
            else:
                logging.debug("got answer for %r; assuming terminal and returning", domain)
            stat["answers"] += 1
            yield from [(domain, j) for j in answers]

parser = argparse.ArgumentParser()
parser.add_argument("-v", "--verbose", action="store_true",
                        help="show more progress information")
parser.add_argument("-i", "--alternative", action="store_true",
                        help="use breadth-first search instead of depth-first")
parser.add_argument("domain", nargs="+",
                        help="IP prefix (CIDR format) or .arpa domain to crawl")
args = parser.parse_args()

logging.basicConfig(level=[logging.INFO, logging.DEBUG][args.verbose],
                    format="%(message)s")

if args.alternative:
    crawl = crawl_iteratively
else:
    crawl = crawl_recursively

domains = []
for arg in args.domain:
    if "/" in arg:
        tmp = [*get_rdns_domains(arg)]
    else:
        tmp = [arg]
    for domain in tmp:
        domain = "%s." % domain.lower().rstrip(".")
        if domain.endswith(".e164.arpa."):
            # max depth really varies by country, let's allow 15 digits
            params = dict(card=10, fmt="%d", rtype="NAPTR", maxdepth=17)
        elif domain.endswith(".ip6.arpa."):
            params = dict(card=16, fmt="%x", rtype="PTR", maxdepth=33)
        elif domain.endswith(".in-addr.arpa."):
            params = dict(card=256, fmt="%d", rtype="PTR", maxdepth=5)
        else:
            exit(f"arpawalk: unrecognized domain: {domain}")
        domains.append((domain, params))

for domain, params in domains:
    stat = {
        "requests": 0,
        "answers": 0,
        "nonterminals": 0,
        "nxdomains": 0,
        "baddelegations": 0,
    }
    for name, rdata in crawl(domain, **params):
        print(fmt_domain(name), "=>", rdata)
    logging.info("crawled %r in %d requests (%d pos, %d mid, %d neg)",
                 domain, stat["requests"], stat["answers"],
                 stat["nonterminals"], stat["nxdomains"])
