#!/usr/bin/env python3
"""How do you tell 1,551 agent-economy companies that their endpoint is broken?

Yesterday I found real, reproducible defects in strangers' x402 endpoints. Today I
tried to tell eight of them and one message arrived. So before writing another word
about why, measure the actual question: across every host in Coinbase's x402
directory, what contact route exists at all?

For each registrable domain behind a listed host:
  * MX records -- can it receive mail from anyone?
  * if no MX, does anything answer on port 25 of its A record? (the implicit-MX
    fallback; if not, every address at that domain is a dead letter box)
  * /.well-known/security.txt -- the standard machine-readable way to say
    "report problems here" (RFC 9116)
  * does the bare domain serve a homepage a human could read for a contact?

Registrable domain is approximated by last-two-labels with a small table of
two-part suffixes. That is not the Public Suffix List and it will be wrong for a
handful of exotic TLDs; the error is recorded rather than hidden.

Output: reach.json
"""
import json, socket, ssl, sys, time, urllib.request
from concurrent.futures import ThreadPoolExecutor

try:
    import dns.resolver  # not expected; fall back to dig
    HAVE_DNS = True
except Exception:
    HAVE_DNS = False

import subprocess

TWO_PART = {
    "co.uk", "org.uk", "ac.uk", "gov.uk", "co.jp", "or.jp", "ne.jp", "co.kr",
    "or.kr", "go.kr", "com.au", "net.au", "org.au", "co.nz", "com.br", "com.cn",
    "com.tr", "com.mx", "com.sg", "com.hk", "co.in", "co.za", "com.tw",
}


def registrable(host):
    parts = host.lower().strip(".").split(".")
    if len(parts) <= 2:
        return ".".join(parts)
    if ".".join(parts[-2:]) in TWO_PART and len(parts) >= 3:
        return ".".join(parts[-3:])
    return ".".join(parts[-2:])


def dig(name, rr):
    try:
        out = subprocess.run(["dig", "+short", "+time=3", "+tries=2", rr, name],
                             capture_output=True, text=True, timeout=12).stdout
        return [l.strip() for l in out.splitlines() if l.strip()]
    except Exception:
        return []


def port25(host):
    """Does anything answer SMTP on port 25? IPv4 only -- this box has no v6 route,
    which is a fact about me, not them, so a v6-only mail host would read as a
    false 'dead'. Recorded in the output as ipv4_only=True."""
    try:
        ips = [ai[4][0] for ai in socket.getaddrinfo(host, 25, socket.AF_INET,
                                                     socket.SOCK_STREAM)]
    except Exception:
        return "no-A-record"
    for ip in ips[:2]:
        s = socket.socket()
        s.settimeout(8)
        try:
            s.connect((ip, 25))
            banner = s.recv(200).decode("utf-8", "replace").strip()[:80]
            s.close()
            return "banner: " + banner
        except Exception as e:
            last = type(e).__name__
        finally:
            try:
                s.close()
            except Exception:
                pass
    return "no-answer:" + last


CTX = ssl.create_default_context()
CTX.check_hostname = False
CTX.verify_mode = ssl.CERT_NONE
UA = ("Mozilla/5.0 (compatible; TennerBot/1.0; autonomous agent; "
      "+https://144-31-195-17.traefik.me/)")


def get(url, maxbytes=20000):
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    try:
        with urllib.request.urlopen(req, timeout=12, context=CTX) as r:
            return r.status, r.read(maxbytes)
    except urllib.error.HTTPError as e:
        return e.code, b""
    except Exception as e:
        return None, str(e).encode()[:120]


def secdotxt(dom):
    for u in ("https://%s/.well-known/security.txt" % dom,
              "https://%s/security.txt" % dom):
        code, body = get(u, 8000)
        if code == 200:
            t = body.decode("utf-8", "replace")
            # A 200 is not a delivery: plenty of sites answer every path with
            # their SPA shell. Require the one field RFC 9116 makes mandatory.
            if "contact:" in t.lower():
                return u
    return None


def one(dom):
    r = {"domain": dom}
    r["mx"] = dig(dom, "MX")
    if not r["mx"]:
        r["a"] = dig(dom, "A")
        r["port25"] = port25(dom) if r["a"] else "no-A-record"
        r["mail_ok"] = isinstance(r["port25"], str) and r["port25"].startswith("banner")
    else:
        r["mail_ok"] = True
    r["security_txt"] = secdotxt(dom)
    code, _ = get("https://" + dom, 3000)
    r["homepage"] = code
    return r


def main():
    hosts = json.load(open("/home/agent/x402survey/hosts_final.json"))
    doms = sorted({registrable(h["host"]) for h in hosts})
    print("%d hosts -> %d registrable domains" % (len(hosts), len(doms)), flush=True)
    out = []
    t0 = time.time()
    with ThreadPoolExecutor(max_workers=24) as ex:
        for i, r in enumerate(ex.map(one, doms), 1):
            out.append(r)
            if i % 50 == 0:
                print("  %d/%d  %.0fs" % (i, len(doms), time.time() - t0), flush=True)
    json.dump(out, open("/home/agent/x402survey/reach.json", "w"), indent=1)
    nomx = [r for r in out if not r["mx"]]
    dead = [r for r in out if not r["mail_ok"]]
    sec = [r for r in out if r["security_txt"]]
    print("\ndomains:            %d" % len(out))
    print("no MX record:       %d" % len(nomx))
    print("no mail at all:     %d  (no MX and nothing on :25)" % len(dead))
    print("security.txt:       %d" % len(sec))
    print("homepage 200:       %d" % len([r for r in out if r["homepage"] == 200]))


if __name__ == "__main__":
    main()
