#!/usr/bin/env python3
"""Quick triage of a SAND network capture: every host the client touched, in order,
with the bits we care about highlighted.

Pulls:
  - DNS queries (order = startup sequence; flush DNS first for a clean list)
  - TLS SNI (HTTPS hostnames even when DNS was cached)
  - TCP/UDP endpoints
and flags the three backends we care about:
  - <titleId>.playfabapi.com   -> prints the **PlayFab TitleId** (the one constant the
    REST scraper needs)
  - <region>.hologryph.com     -> the master-server region (ws://, port 80, cleartext)
  - sandconfigstorage.blob...  -> the anonymous config CDN

Usage:  venv/bin/python reverse/capture_hosts.py <capture.pcapng>
"""
import sys, re
from scapy.all import rdpcap, DNS, DNSQR, DNSRR, IP, IPv6, TCP, UDP, Raw


def tls_sni(b):
    """Extract SNI from a TLS ClientHello payload (bytes). None if not a ClientHello."""
    try:
        if len(b) < 6 or b[0] != 0x16 or b[5] != 0x01:
            return None
        i = 5 + 4 + 2 + 32                       # rec hdr + hs hdr + version + random
        i += 1 + b[i]                            # session id
        i += 2 + int.from_bytes(b[i:i + 2], "big")   # cipher suites
        i += 1 + b[i]                            # compression methods
        end = i + 2 + int.from_bytes(b[i:i + 2], "big")
        i += 2
        while i + 4 <= end:
            et = int.from_bytes(b[i:i + 2], "big")
            el = int.from_bytes(b[i + 2:i + 4], "big")
            i += 4
            if et == 0:                          # server_name
                j = i + 2
                nl = int.from_bytes(b[j + 1:j + 3], "big")
                return b[j + 3:j + 3 + nl].decode(errors="replace")
            i += el
    except Exception:
        return None
    return None


PLAYFAB = re.compile(r"^([0-9A-Fa-f]{4,7})\.playfabapi\.com$")
HOLOGRYPH = re.compile(r"^([a-z0-9-]+)\.hologryph\.com$", re.I)


def main():
    if len(sys.argv) < 2:
        sys.exit("usage: capture_hosts.py <pcap>")
    pk = rdpcap(sys.argv[1])
    t0 = float(pk[0].time)

    dns_order, dns_seen = [], set()
    ip2host, snis = {}, {}
    tcp_first, udp_first = {}, {}

    for p in pk:
        if p.haslayer(DNS):
            d = p[DNS]
            try:
                qn = p[DNSQR].qname.decode(errors="replace").rstrip(".")
            except Exception:
                qn = None
            if qn and d.qr == 0 and not qn.endswith(".local") and qn != "wpad.localdomain":
                if qn not in dns_seen:
                    dns_seen.add(qn)
                    dns_order.append((float(p.time) - t0, qn))
            if qn and d.qr == 1 and d.ancount:
                for k in range(d.ancount):
                    rr = d.an[k]
                    if rr.type in (1, 28):
                        try:
                            ip2host[str(rr.rdata)] = qn
                        except Exception:
                            pass
        ipl = p[IP] if p.haslayer(IP) else (p[IPv6] if p.haslayer(IPv6) else None)
        if ipl is None:
            continue
        if p.haslayer(TCP):
            t = p[TCP]
            if t.flags & 0x02 and not t.flags & 0x10:
                tcp_first.setdefault((ipl.dst, t.dport), float(p.time) - t0)
            if p.haslayer(Raw) and t.dport == 443:
                s = tls_sni(bytes(p[Raw].load))
                if s:
                    snis[ipl.dst] = s
        elif p.haslayer(UDP):
            u = p[UDP]
            if u.dport not in (53, 5353, 1900, 5355, 137) and u.sport not in (53, 5353):
                key = (ipl.dst, u.dport) if u.dport < u.sport else (ipl.src, u.sport)
                udp_first.setdefault(key, float(p.time) - t0)

    def label(ip):
        return snis.get(ip) or ip2host.get(ip, "")

    print("=== DNS queries (in order) ===")
    for ts, q in dns_order:
        print("  +%7.2fs  %s" % (ts, q))

    print("\n=== TCP destinations (first SYN) ===")
    for (ip, port), ts in sorted(tcp_first.items(), key=lambda x: x[1]):
        print("  +%7.2fs  %-17s :%-5s %s" % (ts, ip, port, label(ip)))

    print("\n=== UDP destinations ===")
    for (ip, port), ts in sorted(udp_first.items(), key=lambda x: x[1])[:20]:
        print("  +%7.2fs  %-17s :%-5s %s" % (ts, ip, port, label(ip)))

    # ---- the three backends we care about ----
    print("\n=== BACKENDS DETECTED ===")
    allhosts = set(q for _, q in dns_order) | set(snis.values()) | set(ip2host.values())
    found = False
    for h in sorted(allhosts):
        m = PLAYFAB.match(h)
        if m:
            print("  PlayFab        host=%s   ** TitleId = %s **" % (h, m.group(1).upper()))
            found = True
        elif HOLOGRYPH.match(h) and "gameclient" not in h:
            print("  Master server  host=%s   (region=%s, ws://80 cleartext)"
                  % (h, HOLOGRYPH.match(h).group(1)))
            found = True
        elif "sandconfigstorage" in h:
            print("  Config CDN     host=%s   (anonymous HTTPS)" % h)
            found = True
    if not found:
        print("  (none of PlayFab / hologryph / config-blob seen — backend not contacted)")


if __name__ == "__main__":
    main()
