#!/usr/bin/env python3
from __future__ import annotations
"""
LMO egress-tester client  v1.00
-----------------------------------------------------------------------------------------
Probes:
  --tcp       raw TCP SYN probes (Scapy, requires root)
  --tfo       TFO SYN+data probes (Scapy, requires root)
  --udp       raw UDP (Scapy, requires root)
  --icmp      ICMP echo (type 8) + type 11 (TTL exceeded) + type 3 (dest unreachable)
  --dns       DNS query (Scapy, requires root)
  --gre       GRE encapsulated probe with UUID payload (proto 47)
  --tunnels   All tunnel probes: IPIP (4), GRE (47), ESP (50), AH (51)
  --vxlan     VXLAN/GENEVE overlay probe (UDP 4789, 4790, 6081)
  --sctp      SCTP DATA chunk with UUID payload (proto 132)
  --httpproxy FILE   legacy HTTP GET/CONNECT + CONNECT→TLS ClientHello SNI
  --email     FILE   SMTP probe
  --gnmap     FILE   digest grepable Nmap output into lmo_http/smtp_servers.txt
  -A, --all   Run ALL probes with sensible defaults
"""

import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
warnings.filterwarnings("ignore", category=SyntaxWarning)

import argparse, hashlib, os, random, re, socket, ssl, sys, time, struct, ipaddress
from datetime import datetime
try:
    from datetime import UTC
except ImportError:
    from datetime import timezone
    UTC = timezone.utc
from typing import List, Tuple
from scapy.all import (
    IP, IPv6,
    TCP, UDP,
    ICMP, ICMPv6EchoRequest,
    GRE,
    SCTP, SCTPChunkData,
    DNS, DNSQR,
    RandShort, send, conf,
)

# ── defaults ─────────────────────────────────────────────────────────
PUBLIC_HOST            = "15.235.162.5"
PUBLIC_HOST_DOMAIN     = "egress.hackprocess.com"
HTTP_TEST_PATH_FMT     = "/{uuid}"
SMTP_FROM              = "lmotest@hackprocess.com"
SMTP_TO                = "lmotest@hackprocess.com"
SOCKET_TIMEOUT         = 4

TARGET_HOST            = PUBLIC_HOST
DNS_QUERY_FMT          = "{uuid}.ns1.lmo.hackprocess.com"
TCP_UDP_DEFAULT_PORTS  = [80, 443]
LOG_FILE               = "lmo-client.log"
SYN_GAP                = 0.03
BASE_WINDOW            = 0x7000

# ICMP types to probe beyond echo (type 8)
# type 3  = destination unreachable (code 1 = host unreachable)
# type 11 = TTL exceeded (code 0 = TTL exceeded in transit)
ICMP_EXTRA_TYPES = [
    (3,  1, "dest-unreachable"),
    (11, 0, "ttl-exceeded"),
]
# ─────────────────────────────────────────────────────────────────────

conf.verb = 0

# Patch Scapy socket cleanup bug: some versions raise AttributeError
# ('send_socks') in SuperSocket.__del__, spamming stderr with tracebacks.
try:
    from scapy.supersocket import SuperSocket as _SS
    _orig_del = _SS.__del__
    def _quiet_del(self):
        try:
            _orig_del(self)
        except Exception:
            pass
    _SS.__del__ = _quiet_del
except Exception:
    pass


# ── helpers ──────────────────────────────────────────────────────────
def h32(u):
    return int.from_bytes(hashlib.sha256(u.encode()).digest()[:4], "big")


def port_list(spec):
    if not spec:
        return TCP_UDP_DEFAULT_PORTS
    if spec.lower() == "all":
        return list(range(1, 65536))
    out = []
    for chunk in spec.split(","):
        if "-" in chunk:
            lo, hi = map(int, chunk.split("-", 1))
            out.extend(range(lo, hi + 1))
        else:
            out.append(int(chunk))
    return sorted({p for p in out if 1 <= p <= 65535})


def sendpkt(pkt, dst_ip):
    try:
        send(pkt, verbose=False, iface_hint=dst_ip)
    except TypeError:
        send(pkt, verbose=False)


def log_line(msg: str):
    ts = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
    with open(LOG_FILE, "a") as fh:
        fh.write(f"[{ts} UTC] {msg}\n")


def log_send(proto, dst, port, uuid):
    log_line(f"SENT {proto:<8} → {dst}:{port} UUID={uuid}")


def log_connect_sent(proxy, host, port, uuid):
    log_line(f"SENT CONNECT {proxy} -> {host}:{port} UUID={uuid}")


def log_connect_ok(proxy, host, port, uuid):
    log_line(f"OK_CONNECT     {proxy} -> {host}:{port} UUID={uuid}")


def log_connect_fail(proxy, host, port, uuid, reason):
    log_line(f"FAIL_CONNECT   {proxy} -> {host}:{port} UUID={uuid} REASON={reason}")


def log_ch_sent(proxy, host, port, sni, uuid):
    log_line(f"SENT CH        {proxy} -> {host}:{port} SNI={sni} UUID={uuid}")


def log_ch_ok(proxy, host, port, uuid):
    log_line(f"OK_CH          {proxy} -> {host}:{port} UUID={uuid}")


def log_ch_investigate(proxy, host, port, uuid, reason):
    log_line(f"INVESTIGATE_CH {proxy} -> {host}:{port} UUID={uuid} REASON={reason}")


def log_ch_fail(proxy, host, port, uuid, reason):
    log_line(f"FAIL_CH        {proxy} -> {host}:{port} UUID={uuid} REASON={reason}")


# ── grepable-Nmap digest ─────────────────────────────────────────────
HTTP_KWS = {
    "http", "https", "http-alt", "http-proxy", "ssl/http", "sun-answerbook",
}
SMTP_KWS = {"smtp", "smtps", "submission", "submissions"}


def parse_gnmap(path: str) -> Tuple[List[str], List[str]]:
    """Return (http_list, smtp_list) as ip:port strings."""
    http, smtp = [], []
    host_re = re.compile(r"^Host:\s+(\S+)\s+")
    with open(path) as fh:
        for line in fh:
            if not line.startswith("Host:") or "Ports:" not in line:
                continue
            m = host_re.match(line)
            if not m:
                continue
            ip = m.group(1)
            ports_field = line.split("Ports:", 1)[1]
            for entry in ports_field.split(","):
                fld = entry.strip().split("/")
                if len(fld) < 5:
                    continue
                port, state, _, _, service = fld[:5]
                if state != "open":
                    continue
                service = service.lower()
                pair = f"{ip}:{port}"
                if service in HTTP_KWS:
                    http.append(pair)
                elif service in SMTP_KWS:
                    smtp.append(pair)
    return http, smtp


def write_lists(http: List[str], smtp: List[str]):
    with open("lmo_http_servers.txt", "w") as f:
        f.write("\n".join(http) + ("\n" if http else ""))
    with open("lmo_smtp_servers.txt", "w") as f:
        f.write("\n".join(smtp) + ("\n" if smtp else ""))
    log_line(f"INFO  DIGEST wrote {len(http)} HTTP and {len(smtp)} SMTP hosts")


# ── Legacy HTTP proxy probe ───────────────────────────────────────────
def probe_http_target(uuid: str, target: str, debug=False):
    host, port = target.split(":")
    port = int(port)
    path = HTTP_TEST_PATH_FMT.format(uuid=uuid)
    payload_get = (
        f"GET http://{PUBLIC_HOST}{path} HTTP/1.1\r\nHost: {PUBLIC_HOST}\r\nConnection: close\r\n\r\n".encode()
    )
    payload_connect = (
        f"CONNECT {PUBLIC_HOST}:443 HTTP/1.1\r\nHost: {PUBLIC_HOST}\r\nConnection: close\r\n\r\n".encode()
    )
    for label, payload in (("GET", payload_get), ("CONNECT", payload_connect)):
        try:
            with socket.create_connection((host, port), timeout=SOCKET_TIMEOUT) as s:
                s.sendall(payload)
                data = s.recv(128)
            if b"200" in data or b"Established" in data:
                log_line(f"OK_HTTP  {label:<7} {target} UUID={uuid}")
                return True
        except Exception as e:
            if debug:
                print(f"[DBG] HTTP {label} fail {target}: {e}")
    log_line(f"FAIL_HTTP        {target} UUID={uuid}")
    return False


# ── SMTP probe ───────────────────────────────────────────────────────
def probe_smtp_target(uuid: str, target: str, debug=False):
    host, port_s = target.split(":")
    port = int(port_s)
    use_ssl = port == 465
    context = ssl.create_default_context()
    s = None
    try:
        raw = socket.create_connection((host, port), timeout=SOCKET_TIMEOUT)
        s = context.wrap_socket(raw, server_hostname=host) if use_ssl else raw
        s.settimeout(SOCKET_TIMEOUT)

        def recv():
            return s.recv(512).decode(errors="ignore")

        def smtp_send(cmd):
            if debug:
                print("[DBG] SMTP →", cmd.strip())
            s.sendall(cmd.encode())

        recv()
        smtp_send("EHLO lmo\r\n")
        resp = recv()
        if not use_ssl and "STARTTLS" in resp:
            smtp_send("STARTTLS\r\n"); recv()
            s = context.wrap_socket(s, server_hostname=host)
            smtp_send("EHLO lmo\r\n"); recv()
        smtp_send(f"MAIL FROM:<{SMTP_FROM}>\r\n"); recv()
        smtp_send(f"RCPT TO:<{SMTP_TO}>\r\n"); recv()
        smtp_send("DATA\r\n"); recv()
        smtp_send(f"Subject: LMO test {uuid}\r\n\r\nLMO {uuid}\r\n.\r\n"); recv()
        smtp_send("QUIT\r\n")
        log_line(f"OK_SMTP         {target} UUID={uuid}")
        return True
    except Exception as e:
        if debug:
            print(f"[DBG] SMTP fail {target}: {e}")
        log_line(f"FAIL_SMTP       {target} UUID={uuid}")
    finally:
        if s:
            try:
                s.close()
            except Exception:
                pass
    return False


# ── CONNECT → minimal TLS ClientHello with SNI ───────────────────────
def _build_client_hello(hostname: str) -> bytes:
    rnd = os.urandom(32)
    session = b""
    ciphers = [
        0xC02F,
        0xC02B,
        0x009C,
        0x003C,
        0x002F,
    ]
    cipher_bytes = b"".join(struct.pack("!H", c) for c in ciphers)
    comp_methods = b"\x00"

    hn = hostname.encode("idna")
    server_name = b"\x00" + struct.pack("!H", len(hn)) + hn
    sni_list = struct.pack("!H", len(server_name)) + server_name
    ext_sni = struct.pack("!H", 0x0000) + struct.pack("!H", len(sni_list)) + sni_list
    extensions = ext_sni
    ext_block = struct.pack("!H", len(extensions)) + extensions

    client_hello = (
        b"\x03\x03"
        + rnd
        + bytes([len(session)])
        + session
        + struct.pack("!H", len(cipher_bytes)) + cipher_bytes
        + bytes([len(comp_methods)]) + comp_methods
        + ext_block
    )
    hs = b"\x01" + struct.pack("!I", len(client_hello))[1:] + client_hello
    record = b"\x16\x03\x01" + struct.pack("!H", len(hs)) + hs
    return record


def _recv_once(sock, max_bytes=512, timeout=SOCKET_TIMEOUT):
    sock.settimeout(timeout)
    try:
        return sock.recv(max_bytes)
    except Exception:
        return b""


def _connect_via_proxy(proxy_host, proxy_port, dst_host, dst_port, uuid, debug=False):
    req = f"CONNECT {dst_host}:{dst_port} HTTP/1.1\r\nHost: {dst_host}:{dst_port}\r\nConnection: keep-alive\r\n\r\n".encode()
    try:
        s = socket.create_connection((proxy_host, proxy_port), timeout=SOCKET_TIMEOUT)
        log_connect_sent(f"{proxy_host}:{proxy_port}", dst_host, dst_port, uuid)
        s.sendall(req)
        resp = _recv_once(s, max_bytes=512, timeout=SOCKET_TIMEOUT)
        rlow = resp.lower()
        if b" 200 " in rlow or b"200 connection established" in rlow or b"connection established" in rlow:
            log_connect_ok(f"{proxy_host}:{proxy_port}", dst_host, dst_port, uuid)
            return s
        else:
            reason = (resp[:160] or b"<no-reply>").decode(errors="ignore").replace("\r", " ").replace("\n", " ")
            log_connect_fail(f"{proxy_host}:{proxy_port}", dst_host, dst_port, uuid, reason)
            try:
                s.close()
            except Exception:
                pass
            return None
    except Exception as e:
        log_connect_fail(f"{proxy_host}:{proxy_port}", dst_host, dst_port, uuid, str(e))
        return None


def probe_proxy_tlsch(uuid: str, proxy: str, ports=(80, 443), debug=False):
    host, port = proxy.split(":")
    port = int(port)
    sni = f"{uuid}.{PUBLIC_HOST_DOMAIN}"
    results = {"CONNECT_OK": 0, "CONNECT_FAIL": 0, "CH_OK": 0, "CH_INVESTIGATE": 0, "CH_FAIL": 0}
    for dst_port in ports:
        s = _connect_via_proxy(host, port, PUBLIC_HOST, dst_port, uuid, debug=debug)
        if not s:
            results["CONNECT_FAIL"] += 1
            continue
        results["CONNECT_OK"] += 1
        try:
            ch = _build_client_hello(sni)
            log_ch_sent(f"{host}:{port}", PUBLIC_HOST, dst_port, sni, uuid)
            s.sendall(ch)
            s.close()
            log_ch_ok(f"{host}:{port}", PUBLIC_HOST, dst_port, uuid)
            results["CH_OK"] += 1
        except Exception as e:
            try:
                s.close()
            except Exception:
                pass
            log_ch_investigate(f"{host}:{port}", PUBLIC_HOST, dst_port, uuid, str(e))
            results["CH_INVESTIGATE"] += 1
    return results


# ── TCP probe ────────────────────────────────────────────────────────
def send_tcp(uuid, dst_ip, ports, ipv6, debug=False, srcaddr=None):
    H = h32(uuid); tsval = H; tsecr = H & 0xFFFF; seq = H; ip_id = H & 0xFFFF
    win = (BASE_WINDOW & 0xF000) | (H & 0x0FFF)
    if ipv6:
        ipL = IPv6(dst=dst_ip)
        if srcaddr:
            ipL.src = srcaddr
    else:
        ipL = IP(dst=dst_ip, id=ip_id)
        if srcaddr:
            ipL.src = srcaddr
    sent = 0
    for dport in ports:
        sport = RandShort()
        tcp_base = TCP(sport=sport, dport=dport, flags="S", seq=seq,
                       window=win, options=[("MSS", 1460), ("Timestamp", (tsval, tsecr))])
        for i in range(2):
            sendpkt(ipL / tcp_base, dst_ip)
            if debug:
                print(f"[DBG] SYN{i+1} {dst_ip}:{dport} from {srcaddr or '<auto>'}")
            time.sleep(SYN_GAP)
        log_send("TCP", dst_ip, dport, uuid)
        sent += 2
    return sent


# ── TFO probe ────────────────────────────────────────────────────────
def send_tfo(uuid, dst_ip, ports, ipv6, debug=False, srcaddr=None):
    H = h32(uuid); tsval = H; tsecr = H & 0xFFFF; seq = H; ip_id = H & 0xFFFF
    win = (BASE_WINDOW & 0xF000) | (H & 0x0FFF)
    if ipv6:
        ipL = IPv6(dst=dst_ip)
        if srcaddr:
            ipL.src = srcaddr
    else:
        ipL = IP(dst=dst_ip, id=ip_id)
        if srcaddr:
            ipL.src = srcaddr
    sent = 0
    for dport in ports:
        tfo_cookie = os.urandom(8)
        tfo_syn = ipL / TCP(
            flags="S",
            sport=RandShort(),
            dport=dport,
            seq=seq,
            window=win,
            options=[
                ("MSS", 1460),
                ("NOP", None),
                ("WScale", 6),
                ("NOP", None),
                ("NOP", None),
                ("Timestamp", (tsval, tsecr)),
                ("SAckOK", b""),
                (34, tfo_cookie),
            ]
        ) / uuid.encode()
        sendpkt(tfo_syn, dst_ip)
        log_send("TFOSYN", dst_ip, dport, uuid)
        if debug:
            print(f"[DBG] TFO-SYN {dst_ip}:{dport} cookie={tfo_cookie.hex()}")
        sent += 1
    return sent


# ── UDP probe ────────────────────────────────────────────────────────
def send_udp(u, dst_ip, ports, ipv6, srcaddr=None):
    if ipv6:
        ipL_base = IPv6(dst=dst_ip)
        if srcaddr:
            ipL_base.src = srcaddr
    else:
        ipL_base = IP(dst=dst_ip)
        if srcaddr:
            ipL_base.src = srcaddr
    base = u.encode(); sent = 0
    for d in ports:
        pad = os.urandom(random.randint(0, 32))
        sendpkt(ipL_base / UDP(sport=RandShort(), dport=d) / (base + pad), dst_ip)
        log_send("UDP", dst_ip, d, u); sent += 1
    return sent


# ── ICMP probe ───────────────────────────────────────────────────────
def send_icmp(uuid_str, dst_ip, ipv6, srcaddr=None):
    """
    Sends multiple ICMP probe types:
      IPv4: type 8  (echo request)       — standard ping
            type 11 (TTL exceeded)       — some FWs allow all ICMP error types
            type 3  (dest unreachable)   — same oversight, different type
      IPv6: ICMPv6 echo request only
            (type 3/11 equivalents are ICMPv6 types 1/3 but less commonly misconfigured)
    UUID is carried in the payload for all types.
    """
    payload = uuid_str.encode()
    sent = 0

    if ipv6:
        ip_layer = IPv6(dst=dst_ip)
        if srcaddr:
            ip_layer.src = srcaddr
        pkt = ip_layer / ICMPv6EchoRequest(data=payload)
        sendpkt(pkt, dst_ip)
        log_send("ICMPv6-8", dst_ip, "-", uuid_str)
        sent += 1
    else:
        ip_layer = IP(dst=dst_ip)
        if srcaddr:
            ip_layer.src = srcaddr

        # type 8 — echo request (standard)
        sendpkt(ip_layer / ICMP(type=8, code=0) / payload, dst_ip)
        log_send("ICMP-8", dst_ip, "-", uuid_str)
        sent += 1

        # additional types — carried in the ICMP payload after the standard header
        # type 11 and type 3 normally contain an IP+8-byte inner header, but for
        # egress detection purposes we just need the UUID to arrive at the server.
        # Firewalls that permit "ICMP error messages" broadly will pass these.
        for icmp_type, icmp_code, label in ICMP_EXTRA_TYPES:
            sendpkt(ip_layer / ICMP(type=icmp_type, code=icmp_code) / payload, dst_ip)
            log_send(f"ICMP-{icmp_type}", dst_ip, "-", uuid_str)
            sent += 1

    return sent


# ── GRE probe (proto 47) ─────────────────────────────────────────────
def send_gre(uuid_str, dst_ip, ipv6, srcaddr=None, debug=False):
    """
    GRE encapsulated probe. UUID in GRE payload.
    proto=0x0800 (IPv4) used as inner protocol field to look plausible,
    but payload is raw UUID string — server sniffs raw bytes.
    """
    payload = uuid_str.encode()
    if ipv6:
        ip_layer = IPv6(dst=dst_ip)
        if srcaddr:
            ip_layer.src = srcaddr
    else:
        ip_layer = IP(dst=dst_ip, proto=47)
        if srcaddr:
            ip_layer.src = srcaddr

    pkt = ip_layer / GRE() / payload
    sendpkt(pkt, dst_ip)
    log_send("GRE", dst_ip, "-", uuid_str)
    if debug:
        print(f"[DBG] GRE → {dst_ip} payload={payload}")
    return 1


# ── IPIP probe (proto 4) ─────────────────────────────────────────────
def send_ipip(uuid_str, dst_ip, ipv6, srcaddr=None, debug=False):
    """
    IP-in-IP encapsulated probe (IP proto 4).
    Sends an inner IPv4 packet carrying the UUID payload.
    """
    payload = uuid_str.encode()
    if ipv6:
        ip_layer = IPv6(dst=dst_ip, nh=4)
        if srcaddr:
            ip_layer.src = srcaddr
    else:
        ip_layer = IP(dst=dst_ip, proto=4)
        if srcaddr:
            ip_layer.src = srcaddr

    # Inner IP packet with UUID payload
    inner = IP(dst=dst_ip) / payload
    pkt = ip_layer / inner
    sendpkt(pkt, dst_ip)
    log_send("IPIP", dst_ip, "-", uuid_str)
    if debug:
        print(f"[DBG] IPIP → {dst_ip} payload={payload}")
    return 1


# ── ESP probe (proto 50) ─────────────────────────────────────────────
def send_esp(uuid_str, dst_ip, ipv6, srcaddr=None, debug=False):
    """
    Minimal ESP probe (IP proto 50).
    Sends a fake ESP header (SPI + seq) followed by UUID payload.
    Not a real encrypted packet — just enough to test proto 50 egress.
    """
    payload = uuid_str.encode()
    spi = h32(uuid_str)
    # ESP header: 4-byte SPI + 4-byte sequence number
    esp_hdr = struct.pack("!II", spi, 1)

    if ipv6:
        ip_layer = IPv6(dst=dst_ip, nh=50)
        if srcaddr:
            ip_layer.src = srcaddr
    else:
        ip_layer = IP(dst=dst_ip, proto=50)
        if srcaddr:
            ip_layer.src = srcaddr

    pkt = ip_layer / (esp_hdr + payload)
    sendpkt(pkt, dst_ip)
    log_send("ESP", dst_ip, "-", uuid_str)
    if debug:
        print(f"[DBG] ESP → {dst_ip} SPI={spi:#010x} payload={payload}")
    return 1


# ── AH probe (proto 51) ──────────────────────────────────────────────
def send_ah(uuid_str, dst_ip, ipv6, srcaddr=None, debug=False):
    """
    Minimal AH probe (IP proto 51).
    Sends a fake AH header followed by UUID payload.
    Not a real authenticated packet — just enough to test proto 51 egress.
    """
    payload = uuid_str.encode()
    spi = h32(uuid_str)
    # AH header: next_hdr(1) + payload_len(1) + reserved(2) + SPI(4) + seq(4) + ICV(12 zeros)
    ah_hdr = struct.pack("!BBH", 59, 4, 0)  # next_hdr=59 (no next), len=4 (6 32-bit words - 2)
    ah_hdr += struct.pack("!II", spi, 1)     # SPI + sequence
    ah_hdr += b"\x00" * 12                   # fake ICV (integrity check value)

    if ipv6:
        ip_layer = IPv6(dst=dst_ip, nh=51)
        if srcaddr:
            ip_layer.src = srcaddr
    else:
        ip_layer = IP(dst=dst_ip, proto=51)
        if srcaddr:
            ip_layer.src = srcaddr

    pkt = ip_layer / (ah_hdr + payload)
    sendpkt(pkt, dst_ip)
    log_send("AH", dst_ip, "-", uuid_str)
    if debug:
        print(f"[DBG] AH → {dst_ip} SPI={spi:#010x} payload={payload}")
    return 1


# ── VXLAN probe (UDP 4789/4790/6081) ─────────────────────────────────
def send_vxlan(uuid_str, dst_ip, ipv6, srcaddr=None, debug=False):
    """
    VXLAN/GENEVE overlay probe. Sends UDP packets to ports 4789 (VXLAN),
    4790 (VXLAN-GPE), and 6081 (GENEVE) with an 8-byte VXLAN-style header
    followed by UUID payload.
    """
    payload = uuid_str.encode()
    # VXLAN header: flags(1) + reserved(3) + VNI(3) + reserved(1)
    vni = h32(uuid_str) & 0xFFFFFF
    vxlan_hdr = struct.pack("!I", 0x08000000)  # I flag set
    vxlan_hdr += struct.pack("!I", vni << 8)    # VNI in upper 24 bits

    if ipv6:
        ip_layer = IPv6(dst=dst_ip)
        if srcaddr:
            ip_layer.src = srcaddr
    else:
        ip_layer = IP(dst=dst_ip)
        if srcaddr:
            ip_layer.src = srcaddr

    sent = 0
    for dport in (4789, 4790, 6081):
        pkt = ip_layer / UDP(sport=int(RandShort()), dport=dport) / (vxlan_hdr + payload)
        sendpkt(pkt, dst_ip)
        log_send("VXLAN", dst_ip, dport, uuid_str)
        if debug:
            print(f"[DBG] VXLAN → {dst_ip}:{dport} VNI={vni:#08x} payload={payload}")
        sent += 1
    return sent


# ── SCTP probe (proto 132) ───────────────────────────────────────────
def send_sctp(uuid_str, dst_ip, ports, ipv6, srcaddr=None, debug=False):
    """
    SCTP DATA chunk probe. UUID in chunk data payload.
    Sends to each port in the port list — SCTP uses port numbers like TCP/UDP.
    No handshake attempted; raw DATA chunk is sufficient for server-side detection.
    """
    payload = uuid_str.encode()
    if ipv6:
        ip_layer = IPv6(dst=dst_ip)
        if srcaddr:
            ip_layer.src = srcaddr
    else:
        ip_layer = IP(dst=dst_ip)
        if srcaddr:
            ip_layer.src = srcaddr

    sent = 0
    for dport in ports:
        sport = int(RandShort())
        pkt = (
            ip_layer /
            SCTP(sport=sport, dport=dport) /
            SCTPChunkData(data=payload, proto_id=0)
        )
        sendpkt(pkt, dst_ip)
        log_send("SCTP", dst_ip, dport, uuid_str)
        if debug:
            print(f"[DBG] SCTP → {dst_ip}:{dport} payload={payload}")
        sent += 1
    return sent


# ── DNS probe ────────────────────────────────────────────────────────
def discover_resolver():
    if getattr(conf, "nameservers", None):
        return conf.nameservers[0]
    try:
        with open("/etc/resolv.conf") as f:
            for line in f:
                if line.startswith("nameserver"):
                    return line.split()[1]
    except FileNotFoundError:
        pass
    return "8.8.8.8"


def send_dns(u, srcaddr=None):
    fqdn = DNS_QUERY_FMT.format(uuid=u)
    r = discover_resolver()
    if srcaddr:
        pkt = IP(dst=r, src=srcaddr) / UDP(dport=53) / DNS(rd=1, qd=DNSQR(qname=fqdn))
        send(pkt, verbose=False)
    else:
        sendpkt(IP(dst=r) / UDP(dport=53) / DNS(rd=1, qd=DNSQR(qname=fqdn)), r)
    log_send("DNS", r, 53, u); return 1


# ── misc ─────────────────────────────────────────────────────────────
def must_root():
    if hasattr(os, 'geteuid') and os.geteuid() != 0:
        sys.exit("⚠  root privileges required.")


# ── main ─────────────────────────────────────────────────────────────
def main():
    ap = argparse.ArgumentParser(description="LMO client – egress tester")
    ap.add_argument("--uuid",      help="UUID tag for packets / probes")
    ap.add_argument("--tcp",       action="store_true", help="raw TCP SYN probes")
    ap.add_argument("--tfo",       action="store_true", help="TFO SYN+data probes")
    ap.add_argument("--udp",       action="store_true", help="raw UDP probes")
    ap.add_argument("--icmp",      action="store_true", help="ICMP echo + type 11 (TTL exceeded) + type 3 (dest unreachable)")
    ap.add_argument("--dns",       action="store_true", help="DNS query probe")
    ap.add_argument("--gre",       action="store_true", help="GRE encapsulated probe (proto 47)")
    ap.add_argument("--ipip",      action="store_true", help="IPIP encapsulated probe (proto 4)")
    ap.add_argument("--esp",       action="store_true", help="ESP probe (proto 50)")
    ap.add_argument("--ah",        action="store_true", help="AH probe (proto 51)")
    ap.add_argument("--tunnels",   action="store_true", help="All tunnel probes: IPIP (4), GRE (47), ESP (50), AH (51)")
    ap.add_argument("--vxlan",     action="store_true", help="VXLAN/GENEVE overlay probe (UDP 4789, 4790, 6081)")
    ap.add_argument("--sctp",      action="store_true", help="SCTP DATA chunk probe (proto 132)")
    ap.add_argument("-A", "--all", action="store_true", dest="run_all", help="Run ALL probes with sensible defaults")
    ap.add_argument("--ports",     help="ports to probe for TCP/UDP/SCTP (default: 80,443); comma/range or 'all'")
    ap.add_argument("--ipv6",      action="store_true", help="use IPv6")
    ap.add_argument("--debug",     action="store_true", help="verbose debug output")
    ap.add_argument("--gnmap",     metavar="FILE", help="digest grepable Nmap file and exit")
    ap.add_argument("--httpproxy", metavar="FILE", help="file with ip:port list to probe as HTTP proxies")
    ap.add_argument("--email",     metavar="FILE", help="file with ip:port list to probe as SMTP servers")
    ap.add_argument("--srcaddr",   metavar="IP",   help="source IP for raw packet probes (spoof)")

    args = ap.parse_args()

    # ── -A / --all expansion ─────────────────────────────────────────
    if args.run_all:
        args.tcp = True
        args.tfo = True
        args.udp = True
        args.icmp = True
        args.dns = True
        args.tunnels = True
        args.sctp = True
        args.vxlan = True
        if not args.ports:
            args.ports = "1-1000"
        if not args.httpproxy:
            if os.path.isfile("lmo_http_servers.txt"):
                args.httpproxy = "lmo_http_servers.txt"
            else:
                print("[INFO] lmo_http_servers.txt not found, skipping HTTP proxy probes")
        if not args.email:
            if os.path.isfile("lmo_smtp_servers.txt"):
                args.email = "lmo_smtp_servers.txt"
            else:
                print("[INFO] lmo_smtp_servers.txt not found, skipping SMTP probes")

    # ── --tunnels expansion ──────────────────────────────────────────
    if args.tunnels:
        args.gre = True
        args.ipip = True
        args.esp = True
        args.ah = True

    # ── digest-only mode ──────────────────────────────────────────────
    if args.gnmap:
        http, smtp = parse_gnmap(args.gnmap)
        write_lists(http, smtp)
        print(f"Digest complete → lmo_http_servers.txt ({len(http)} lines), lmo_smtp_servers.txt ({len(smtp)} lines)")
        return

    # ── validation ────────────────────────────────────────────────────
    if not args.uuid:
        sys.exit("--uuid is required in probe mode")

    if not any([args.tcp, args.tfo, args.udp, args.icmp, args.dns, args.gre, args.ipip, args.esp, args.ah, args.sctp, args.vxlan, args.httpproxy, args.email]):
        sys.exit("choose at least one probe: --tcp/--tfo/--udp/--icmp/--dns/--gre/--tunnels/--vxlan/--sctp/--httpproxy/--email/-A")

    if any([args.tcp, args.tfo, args.udp, args.icmp, args.dns, args.gre, args.ipip, args.esp, args.ah, args.sctp, args.vxlan]):
        must_root()

    srcaddr = None
    if args.srcaddr:
        try:
            ipobj = ipaddress.ip_address(args.srcaddr)
            if args.ipv6 and ipobj.version != 6:
                sys.exit("Error: --srcaddr is IPv4 but --ipv6 was specified.")
            if not args.ipv6 and ipobj.version != 4:
                sys.exit("Error: --srcaddr is IPv6 but --ipv6 was not specified.")
            srcaddr = args.srcaddr
        except Exception:
            sys.exit("Error: --srcaddr is not a valid IP address.")

    ports = port_list(args.ports)
    fam = socket.AF_INET6 if args.ipv6 else socket.AF_INET
    dst_ip = socket.getaddrinfo(TARGET_HOST, None, fam)[0][4][0]

    print(f"LMO client → {dst_ip}  UUID={args.uuid} SRC={srcaddr or '<auto>'}")

    counts = dict(
        TCP=0, TFOSYN=0, UDP=0,
        ICMP_8=0, ICMP_11=0, ICMP_3=0,
        DNS=0, GRE=0, IPIP=0, ESP=0, AH=0, SCTP=0, VXLAN=0,
        HTTP=0, SMTP=0,
        CONNECT_OK=0, CONNECT_FAIL=0, CH_OK=0, CH_INVESTIGATE=0, CH_FAIL=0,
    )

    if args.tcp:
        counts["TCP"] = send_tcp(args.uuid, dst_ip, ports, args.ipv6, args.debug,
                                 srcaddr=srcaddr)

    if args.tfo:
        counts["TFOSYN"] = send_tfo(args.uuid, dst_ip, ports, args.ipv6, args.debug,
                                    srcaddr=srcaddr)

    if args.udp:
        counts["UDP"] = send_udp(args.uuid, dst_ip, ports, args.ipv6, srcaddr=srcaddr)

    if args.icmp:
        sent = send_icmp(args.uuid, dst_ip, args.ipv6, srcaddr=srcaddr)
        if args.ipv6:
            counts["ICMP_8"] = sent
        else:
            counts["ICMP_8"]  = 1
            counts["ICMP_11"] = 1
            counts["ICMP_3"]  = 1

    if args.dns:
        counts["DNS"] = send_dns(args.uuid, srcaddr=srcaddr)

    if args.gre:
        counts["GRE"] = send_gre(args.uuid, dst_ip, args.ipv6, srcaddr=srcaddr, debug=args.debug)

    if args.ipip:
        counts["IPIP"] = send_ipip(args.uuid, dst_ip, args.ipv6, srcaddr=srcaddr, debug=args.debug)

    if args.esp:
        counts["ESP"] = send_esp(args.uuid, dst_ip, args.ipv6, srcaddr=srcaddr, debug=args.debug)

    if args.ah:
        counts["AH"] = send_ah(args.uuid, dst_ip, args.ipv6, srcaddr=srcaddr, debug=args.debug)

    if args.vxlan:
        counts["VXLAN"] = send_vxlan(args.uuid, dst_ip, args.ipv6, srcaddr=srcaddr, debug=args.debug)

    if args.sctp:
        counts["SCTP"] = send_sctp(args.uuid, dst_ip, ports, args.ipv6, srcaddr=srcaddr, debug=args.debug)

    if args.httpproxy:
        with open(args.httpproxy) as f:
            targets = [l.strip() for l in f if l.strip()]
        for t in targets:
            try:
                if probe_http_target(args.uuid, t, args.debug):
                    counts["HTTP"] += 1
            except Exception:
                pass
            res = probe_proxy_tlsch(args.uuid, t, ports=(80, 443), debug=args.debug)
            for k in ("CONNECT_OK", "CONNECT_FAIL", "CH_OK", "CH_INVESTIGATE", "CH_FAIL"):
                counts[k] += res[k]

    if args.email:
        with open(args.email) as f:
            targets = [l.strip() for l in f if l.strip()]
        for t in targets:
            if probe_smtp_target(args.uuid, t, args.debug):
                counts["SMTP"] += 1

    # ── summary ───────────────────────────────────────────────────────
    PACKET_TYPES = {"TCP", "TFOSYN", "UDP", "ICMP_8", "ICMP_11", "ICMP_3", "DNS", "GRE", "IPIP", "ESP", "AH", "SCTP", "VXLAN"}
    print("\n=== Summary ===")
    for p, n in counts.items():
        if not n:
            continue
        if p in PACKET_TYPES:
            print(f"{p:>12}: {n} packets")
        elif p == "HTTP":
            print(f"{p:>12}: {n} successful (legacy)")
        else:
            print(f"{p:>12}: {n}")
    print(f"Log → {LOG_FILE}")


if __name__ == "__main__":
    main()
