898 lines
43 KiB
Python
898 lines
43 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
LYNX — the sharp-eyed inspector.
|
||
No-KYC, Bitcoin-paid API for file inspection, steganography, forensics, and recon.
|
||
Powered by local tooling (file/steg/forensics) + Kali (isolated recon egress).
|
||
"""
|
||
import os, io, re, json, time, math, uuid, hashlib, secrets, shutil, subprocess, tempfile, base64
|
||
import sqlite3, threading, collections
|
||
from functools import wraps
|
||
from flask import Flask, request, jsonify, send_from_directory, Response, g
|
||
import urllib.request, urllib.error, ssl
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Config
|
||
# ----------------------------------------------------------------------------
|
||
PORT = int(os.environ.get("LYNX_PORT", 5059))
|
||
DB_PATH = os.environ.get("LYNX_DB", "/opt/lynx/lynx.db")
|
||
WORK_DIR = "/opt/lynx/work"
|
||
PUBLIC_DOMAIN = os.environ.get("LYNX_DOMAIN", "lynx.thetempleofdoom.com")
|
||
|
||
# Kali recon engine (isolated red-team segment)
|
||
KALI_API = os.environ.get("KALI_API", "http://10.30.30.177:5000")
|
||
|
||
# BTCPay
|
||
BTCPAY_URL = os.environ.get("BTCPAY_URL", "https://10.30.20.140")
|
||
BTCPAY_KEY = os.environ.get("BTCPAY_KEY", "b1d5016e3b2197882c0671cefe080ccf7c2ebb2e")
|
||
BTCPAY_STORE = os.environ.get("BTCPAY_STORE", "2riBfb6pMUnSKsmB2RwokExz37f2spyghE9WmWsn2iWo")
|
||
BTCPAY_WEBHOOK_SECRET = os.environ.get("BTCPAY_WSEC", "BHhQC4ZfBnbQemYqSoksEA")
|
||
BTCPAY_PUBLIC = os.environ.get("BTCPAY_PUBLIC", "https://btcpay.thetempleofdoom.com")
|
||
|
||
ADMIN_KEY = os.environ.get("LYNX_ADMIN_KEY", "sk-lynx-admin-" + secrets.token_hex(12))
|
||
|
||
# Pricing (USD -> credits)
|
||
TIERS = {2: 20, 5: 60, 10: 150, 20: 400}
|
||
# Tool cost (credits)
|
||
COSTS = {
|
||
"inspect/file": 1, "steg/extract": 2, "steg/crack": 4,
|
||
"forensics/disk": 3,
|
||
"recon/nmap": 3, "recon/subfinder": 2, "recon/nuclei": 4,
|
||
"recon/theharvester": 2, "recon/dnsrecon": 2,
|
||
}
|
||
|
||
MAX_UPLOAD = 20 * 1024 * 1024 # 20MB
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# DB
|
||
# ----------------------------------------------------------------------------
|
||
def db():
|
||
c = getattr(g, "_db", None)
|
||
if c is None:
|
||
c = g._db = sqlite3.connect(DB_PATH, timeout=15)
|
||
c.row_factory = sqlite3.Row
|
||
return c
|
||
|
||
def init_db():
|
||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||
c = sqlite3.connect(DB_PATH, timeout=15)
|
||
c.executescript("""
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
email TEXT UNIQUE NOT NULL,
|
||
api_key TEXT UNIQUE NOT NULL,
|
||
credits INTEGER DEFAULT 0,
|
||
total_calls INTEGER DEFAULT 0,
|
||
is_admin INTEGER DEFAULT 0,
|
||
created_at INTEGER,
|
||
last_used_at INTEGER
|
||
);
|
||
CREATE TABLE IF NOT EXISTS orders (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
order_id TEXT UNIQUE NOT NULL,
|
||
api_key TEXT,
|
||
invoice_id TEXT,
|
||
credits INTEGER,
|
||
status TEXT DEFAULT 'pending',
|
||
created_at INTEGER,
|
||
settled_at INTEGER
|
||
);
|
||
CREATE TABLE IF NOT EXISTS usage_log (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
api_key TEXT,
|
||
tool TEXT,
|
||
target TEXT,
|
||
credits INTEGER,
|
||
created_at INTEGER
|
||
);
|
||
""")
|
||
c.commit()
|
||
c.close()
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Helpers
|
||
# ----------------------------------------------------------------------------
|
||
TOOL_PATHS = {}
|
||
def resolve_tools():
|
||
"""Resolve tool binary paths (some pip/gem tools land in /usr/local/bin)."""
|
||
for name, candidates in {
|
||
"steghide": ["steghide"], "binwalk": ["binwalk"], "foremost": ["foremost"],
|
||
"exiftool": ["exiftool"], "ssdeep": ["ssdeep"], "strings": ["strings"],
|
||
"file": ["file"], "mmls": ["mmls"], "fls": ["fls"], "fsstat": ["fsstat"],
|
||
"tsk_recover": ["tsk_recover"], "stegcracker": ["stegcracker"],
|
||
"zsteg": ["zsteg"], "olevba": ["olevba"], "pdftotext": ["pdftotext"],
|
||
}.items():
|
||
for cand in candidates:
|
||
p = shutil.which(cand) or (("/usr/local/bin/" + cand) if os.path.exists("/usr/local/bin/" + cand) else None)
|
||
if p:
|
||
TOOL_PATHS[name] = p
|
||
break
|
||
|
||
def run(cmd, timeout=120):
|
||
try:
|
||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||
return r.returncode, r.stdout, r.stderr
|
||
except subprocess.TimeoutExpired:
|
||
return -1, "", "timeout"
|
||
except Exception as e:
|
||
return -1, "", str(e)
|
||
|
||
def sha256_file(p):
|
||
h = hashlib.sha256()
|
||
with open(p, "rb") as f:
|
||
for chunk in iter(lambda: f.read(65536), b""):
|
||
h.update(chunk)
|
||
return h.hexdigest()
|
||
|
||
def file_hashes(p):
|
||
md5 = hashlib.md5(); sha1 = hashlib.sha1(); sha256 = hashlib.sha256()
|
||
with open(p, "rb") as f:
|
||
for chunk in iter(lambda: f.read(65536), b""):
|
||
md5.update(chunk); sha1.update(chunk); sha256.update(chunk)
|
||
return md5.hexdigest(), sha1.hexdigest(), sha256.hexdigest()
|
||
|
||
def entropy(p):
|
||
freq = [0] * 256
|
||
total = 0
|
||
with open(p, "rb") as f:
|
||
for chunk in iter(lambda: f.read(65536), b""):
|
||
for b in chunk:
|
||
freq[b] += 1; total += 1
|
||
if total == 0:
|
||
return 0.0
|
||
e = 0.0
|
||
for c in freq:
|
||
if c:
|
||
px = c / total
|
||
e -= px * math.log2(px)
|
||
return round(e, 4)
|
||
|
||
def ssdeep_hash(p):
|
||
rc, out, err = run([TOOL_PATHS.get("ssdeep", "ssdeep"), "-b", p], timeout=60)
|
||
m = re.search(r"\d+:[A-Za-z0-9+/]+:[A-Za-z0-9+/]+", out)
|
||
return m.group(0) if m else None
|
||
|
||
def new_key():
|
||
return "sk-lynx-" + secrets.token_hex(24)
|
||
|
||
def get_key():
|
||
return request.headers.get("X-API-Key") or request.args.get("api_key") or ""
|
||
|
||
def auth(gate=True):
|
||
key = get_key()
|
||
if not key:
|
||
return None, (jsonify({"error": "API key required. Sign up at /api/signup"}), 401)
|
||
row = db().execute("SELECT * FROM users WHERE api_key=?", (key,)).fetchone()
|
||
if not row:
|
||
return None, (jsonify({"error": "Invalid API key"}), 401)
|
||
if gate and not row["is_admin"] and row["credits"] <= 0:
|
||
return None, (jsonify({"error": "No credits. Buy at /api/order"}), 402)
|
||
return row, None
|
||
|
||
_rate = collections.defaultdict(list)
|
||
def rate_limited(ident, limit=10, window=60):
|
||
now = time.time()
|
||
_rate[ident] = [t for t in _rate[ident] if now - t < window]
|
||
if len(_rate[ident]) >= limit:
|
||
return True
|
||
_rate[ident].append(now)
|
||
return False
|
||
|
||
def log_usage(key, tool, target, credits):
|
||
db().execute("INSERT INTO usage_log (api_key, tool, target, credits, created_at) VALUES (?,?,?,?,?)",
|
||
(key, tool, str(target)[:200], credits, int(time.time())))
|
||
db().execute("UPDATE users SET total_calls=total_calls+1, credits=credits-?, last_used_at=? WHERE api_key=?",
|
||
(credits, int(time.time()), key))
|
||
db().commit()
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Kali recon dispatch (isolated egress)
|
||
# ----------------------------------------------------------------------------
|
||
_btc_ctx = ssl.create_default_context(); _btc_ctx.check_hostname = False; _btc_ctx.verify_mode = ssl.CERT_NONE
|
||
|
||
def kali_call(tool, params, timeout=300):
|
||
req = urllib.request.Request(KALI_API + "/api/tools/" + tool,
|
||
data=json.dumps(params).encode(), headers={"Content-Type": "application/json"})
|
||
try:
|
||
resp = urllib.request.urlopen(req, timeout=timeout)
|
||
return json.loads(resp.read().decode())
|
||
except urllib.error.HTTPError as e:
|
||
return {"error": f"kali http {e.code}", "body": e.read().decode()[:500]}
|
||
except Exception as e:
|
||
return {"error": f"kali unreachable: {e}"}
|
||
|
||
SAFE_TARGET = re.compile(r"^[A-Za-z0-9.\-_:/]+$")
|
||
|
||
def clean_target(t):
|
||
if not t or not SAFE_TARGET.match(t) or len(t) > 200:
|
||
return None
|
||
return t
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# File analysis
|
||
# ----------------------------------------------------------------------------
|
||
def analyze_file(path, name):
|
||
"""Full inspection report for a file."""
|
||
md5, sha1, sha256 = file_hashes(path)
|
||
size = os.path.getsize(path)
|
||
rep = {
|
||
"file": name, "size": size,
|
||
"hashes": {"md5": md5, "sha1": sha1, "sha256": sha256},
|
||
"ssdeep": ssdeep_hash(path),
|
||
"entropy": entropy(path),
|
||
}
|
||
rc, out, _ = run([TOOL_PATHS.get("file", "file"), "-b", path])
|
||
rep["type"] = out.strip() if rc == 0 else None
|
||
|
||
rc, out, _ = run([TOOL_PATHS.get("strings", "strings"), "-n", "6", path])
|
||
rep["strings"] = [s for s in out.splitlines() if s.strip()][:40] if rc == 0 else []
|
||
|
||
# exiftool
|
||
if "exiftool" in TOOL_PATHS:
|
||
rc, out, _ = run([TOOL_PATHS["exiftool"], "-j", path], timeout=90)
|
||
if rc == 0:
|
||
try:
|
||
rep["metadata"] = json.loads(out)[0] if out.strip() else {}
|
||
except Exception:
|
||
rep["metadata"] = {"raw": out[:2000]}
|
||
|
||
# binwalk signatures
|
||
if "binwalk" in TOOL_PATHS:
|
||
rc, out, _ = run([TOOL_PATHS["binwalk"], "--signature", "--quiet", path], timeout=120)
|
||
if rc == 0 and out.strip():
|
||
rep["embedded_signatures"] = [l.strip() for l in out.splitlines() if l.strip()][:50]
|
||
|
||
# PE analysis
|
||
try:
|
||
import pefile
|
||
pe = pefile.PE(path)
|
||
rep["pe"] = {
|
||
"machine": hex(pe.FILE_HEADER.Machine), "sections": len(pe.sections),
|
||
"imports": len(getattr(pe, "DIRECTORY_ENTRY_IMPORT", []) or []),
|
||
"compile_time": pe.FILE_HEADER.TimeDateStamp,
|
||
"is_packed": bool(pe.sections and any(getattr(s, "SizeOfRawData", 0) == 0 for s in pe.sections)),
|
||
}
|
||
pe.close()
|
||
except Exception:
|
||
pass
|
||
|
||
# OLE / Office macro scan
|
||
if "olevba" in TOOL_PATHS and name.lower().split(".")[-1] in ("doc", "docx", "xls", "xlsx", "ppt", "pptx", "docm", "xlsm", "pptm"):
|
||
rc, out, _ = run([TOOL_PATHS["olevba"], path], timeout=90)
|
||
if rc == 0 and out.strip():
|
||
rep["office_macros"] = out[:4000]
|
||
|
||
# PDF analysis
|
||
if name.lower().endswith(".pdf"):
|
||
try:
|
||
with open(path, "rb") as f:
|
||
raw = f.read()
|
||
rep["pdf"] = {
|
||
"has_js": b"/JavaScript" in raw or b"/JS" in raw,
|
||
"has_openaction": b"/OpenAction" in raw,
|
||
"has_launch": b"/Launch" in raw,
|
||
"has_embedded_file": b"/EmbeddedFile" in raw,
|
||
"pages": raw.count(b"/Type /Page") or None,
|
||
}
|
||
except Exception:
|
||
pass
|
||
|
||
return rep
|
||
|
||
def steg_extract(path, name, passphrase=""):
|
||
"""steghide + zsteg extraction."""
|
||
out = {}
|
||
if "steghide" in TOOL_PATHS:
|
||
ext = os.path.join(os.path.dirname(path), "extracted.bin")
|
||
cmd = [TOOL_PATHS["steghide"], "extract", "-sf", path, "-xf", ext, "-p", passphrase or "", "-f"]
|
||
rc, so, se = run(cmd, timeout=90)
|
||
if rc == 0:
|
||
with open(ext, "rb") as f:
|
||
data = f.read()
|
||
out["steghide"] = {"extracted_bytes": len(data), "content": data.decode("utf-8", "replace")[:2000]}
|
||
os.remove(ext)
|
||
else:
|
||
out["steghide"] = {"error": (se or so).strip()[:500] or "no embedded data / wrong passphrase"}
|
||
if "zsteg" in TOOL_PATHS:
|
||
rc, so, se = run([TOOL_PATHS["zsteg"], path], timeout=90)
|
||
out["zsteg"] = {"result": (so or se).strip()[:4000]} if rc == 0 else {"error": (se or "no LSB data").strip()[:400]}
|
||
return out
|
||
|
||
STEG_WORDLIST = "/opt/lynx/wordlist.txt"
|
||
|
||
def steg_crack(path, name):
|
||
if not os.path.exists(STEG_WORDLIST):
|
||
return {"error": "wordlist missing"}
|
||
rc, so, se = run([TOOL_PATHS.get("stegcracker", "stegcracker"), path, STEG_WORDLIST], timeout=600)
|
||
return {"result": (so or se).strip()[:4000]}
|
||
|
||
def forensics_disk(path, name):
|
||
"""Sleuth Kit analysis of a disk image."""
|
||
out = {}
|
||
if "mmls" in TOOL_PATHS:
|
||
rc, so, se = run([TOOL_PATHS["mmls"], path], timeout=90)
|
||
out["partitions"] = (so or se).strip()[:2000] if rc == 0 else {"error": se.strip()[:300]}
|
||
if "fsstat" in TOOL_PATHS:
|
||
rc, so, se = run([TOOL_PATHS["fsstat"], path], timeout=90)
|
||
out["fsstat"] = (so or se).strip()[:3000] if rc == 0 else {"error": se.strip()[:300]}
|
||
if "fls" in TOOL_PATHS:
|
||
rc, so, se = run([TOOL_PATHS["fls"], "-r", path], timeout=120)
|
||
out["file_listing"] = [l for l in (so or "").splitlines()][:200] if rc == 0 else {"error": se.strip()[:300]}
|
||
return out
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# App
|
||
# ----------------------------------------------------------------------------
|
||
app = Flask(__name__)
|
||
app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD + 1024 * 1024
|
||
|
||
@app.teardown_appcontext
|
||
def close_db(exc):
|
||
c = getattr(g, "_db", None)
|
||
if c is not None:
|
||
c.close()
|
||
|
||
def save_upload():
|
||
if "file" not in request.files:
|
||
return None, (jsonify({"error": "file field required (multipart/form-data)"}), 400)
|
||
f = request.files["file"]
|
||
if not f or not f.filename:
|
||
return None, (jsonify({"error": "empty file"}), 400)
|
||
d = os.path.join(WORK_DIR, uuid.uuid4().hex)
|
||
os.makedirs(d, exist_ok=True)
|
||
p = os.path.join(d, os.path.basename(f.filename) or "upload.bin")
|
||
f.save(p)
|
||
return p, None
|
||
|
||
# ----------------------------- public -----------------------------
|
||
@app.route("/beacon")
|
||
def beacon():
|
||
return jsonify({"service": "lynx", "status": "ok", "time": int(time.time())})
|
||
|
||
@app.route("/health")
|
||
def health():
|
||
try:
|
||
kali = urllib.request.urlopen(KALI_API + "/health", timeout=6).read().decode()
|
||
kali_ok = "healthy" in kali
|
||
except Exception:
|
||
kali_ok = False
|
||
return jsonify({"status": "ok", "kali": kali_ok, "tools": len(TOOL_PATHS), "time": int(time.time())})
|
||
|
||
@app.route("/stats")
|
||
def stats():
|
||
users = db().execute("SELECT COUNT(*) c FROM users").fetchone()["c"]
|
||
calls = db().execute("SELECT COALESCE(SUM(total_calls),0) c FROM users").fetchone()["c"]
|
||
return jsonify({"users": users, "total_calls": calls, "tools": list(COSTS.keys())})
|
||
|
||
# ----------------------------- auth / billing -----------------------------
|
||
@app.route("/api/signup", methods=["POST"])
|
||
def signup():
|
||
if rate_limited("signup_" + request.remote_addr, limit=5, window=300):
|
||
return jsonify({"error": "rate limited"}), 429
|
||
d = request.json or {}
|
||
email = (d.get("email") or "").strip().lower()
|
||
if not email or "@" not in email or "." not in email:
|
||
return jsonify({"error": "valid email required"}), 400
|
||
key = new_key()
|
||
try:
|
||
db().execute("INSERT INTO users (email, api_key, credits, created_at) VALUES (?,?,?,?)",
|
||
(email, key, 0, int(time.time())))
|
||
db().commit()
|
||
except sqlite3.IntegrityError:
|
||
row = db().execute("SELECT api_key FROM users WHERE email=?", (email,)).fetchone()
|
||
key = row["api_key"]
|
||
return jsonify({"email": email, "api_key": key, "credits": 0,
|
||
"note": "No KYC. Your key has 0 credits — buy at /api/order."})
|
||
|
||
@app.route("/api/pricing")
|
||
def pricing():
|
||
return jsonify({"tiers": TIERS, "costs": COSTS, "currency": "USD", "note": "No KYC, no subscription."})
|
||
|
||
@app.route("/api/order", methods=["POST"])
|
||
def order():
|
||
if rate_limited("order_" + request.remote_addr, limit=10, window=60):
|
||
return jsonify({"error": "rate limited"}), 429
|
||
d = request.json or {}
|
||
key = d.get("api_key") or get_key()
|
||
usd = float(d.get("usd", 5))
|
||
if usd not in TIERS:
|
||
return jsonify({"error": "usd must be one of " + str(sorted(TIERS.keys()))}), 400
|
||
row = db().execute("SELECT * FROM users WHERE api_key=?", (key,)).fetchone()
|
||
if not row:
|
||
return jsonify({"error": "invalid api_key"}), 401
|
||
credits = TIERS[usd]
|
||
order_id = "lynx-" + secrets.token_hex(8)
|
||
# BTCPay invoice
|
||
inv = {
|
||
"amount": str(usd), "currency": "USD", "checkout": {"redirectURL": f"https://{PUBLIC_DOMAIN}/api/order/{order_id}"},
|
||
"metadata": {"orderId": order_id, "api_key": key, "credits": credits},
|
||
}
|
||
req = urllib.request.Request(f"{BTCPAY_URL}/api/v1/stores/{BTCPAY_STORE}/invoices",
|
||
data=json.dumps(inv).encode(), headers={"Content-Type": "application/json", "Authorization": "token " + BTCPAY_KEY})
|
||
try:
|
||
resp = urllib.request.urlopen(req, timeout=20, context=_btc_ctx)
|
||
inv_body = json.loads(resp.read().decode())
|
||
except Exception as e:
|
||
return jsonify({"error": f"invoice failed: {e}"}), 502
|
||
db().execute("INSERT INTO orders (order_id, api_key, invoice_id, credits, created_at) VALUES (?,?,?,?,?)",
|
||
(order_id, key, inv_body.get("id"), credits, int(time.time())))
|
||
db().commit()
|
||
return jsonify({"order_id": order_id, "invoice_id": inv_body.get("id"),
|
||
"credits": credits, "usd": usd,
|
||
"checkout_link": inv_body.get("checkoutLink", "").replace("https://10.30.20.140", BTCPAY_PUBLIC).replace("http://10.30.20.140", BTCPAY_PUBLIC)})
|
||
|
||
@app.route("/api/order/<order_id>")
|
||
def order_status(order_id):
|
||
o = db().execute("SELECT * FROM orders WHERE order_id=?", (order_id,)).fetchone()
|
||
if not o:
|
||
return jsonify({"error": "order not found"}), 404
|
||
if o["status"] == "settled":
|
||
return jsonify({"order_id": order_id, "status": "settled", "credits": o["credits"]})
|
||
return jsonify({"order_id": order_id, "status": o["status"], "note": "awaiting payment"})
|
||
|
||
@app.route("/webhook/btcpay", methods=["POST"])
|
||
def btcpay_webhook():
|
||
body = request.get_json(silent=True) or {}
|
||
# optional HMAC verify
|
||
if request.headers.get("BTCPay-Sig"):
|
||
import hmac as _hmac
|
||
sig = "sha256=" + _hmac.new(BTCPAY_WEBHOOK_SECRET.encode(), request.get_data(), hashlib.sha256).hexdigest()
|
||
if not _hmac.compare_digest(sig, request.headers.get("BTCPay-Sig", "")):
|
||
return jsonify({"error": "bad sig"}), 401
|
||
etype = body.get("type", "")
|
||
meta = body.get("metadata", {})
|
||
if isinstance(meta, dict):
|
||
order_id = meta.get("orderId"); key = meta.get("api_key"); credits = meta.get("credits")
|
||
else:
|
||
order_id = key = credits = None
|
||
if etype in ("InvoiceSettled", "InvoiceProcessing") and order_id:
|
||
o = db().execute("SELECT * FROM orders WHERE order_id=? AND status='pending'", (order_id,)).fetchone()
|
||
if o:
|
||
db().execute("UPDATE orders SET status='settled', settled_at=? WHERE order_id=?", (int(time.time()), order_id))
|
||
db().execute("UPDATE users SET credits=credits+? WHERE api_key=?", (credits, key))
|
||
db().commit()
|
||
return jsonify({"status": "ok"})
|
||
|
||
@app.route("/api/me")
|
||
def me():
|
||
row, err = auth(gate=False)
|
||
if not row:
|
||
return err
|
||
return jsonify({"email": row["email"], "api_key": row["api_key"], "credits": row["credits"],
|
||
"total_calls": row["total_calls"]})
|
||
|
||
# ----------------------------- metered tools -----------------------------
|
||
def metered(tool):
|
||
def deco(fn):
|
||
@wraps(fn)
|
||
def wrapper(*a, **kw):
|
||
row, err = auth(gate=True)
|
||
if not row:
|
||
return err
|
||
cost = COSTS[tool]
|
||
if not row["is_admin"] and row["credits"] < cost:
|
||
return jsonify({"error": f"insufficient credits ({row['credits']} < {cost}). Buy at /api/order"}), 402
|
||
try:
|
||
result = fn(*a, **kw)
|
||
finally:
|
||
pass
|
||
log_usage(row["api_key"], tool, kw.get("target", ""), cost)
|
||
return result
|
||
return wrapper
|
||
return deco
|
||
|
||
@app.route("/api/inspect/file", methods=["POST"])
|
||
@metered("inspect/file")
|
||
def inspect_file():
|
||
p, err = save_upload()
|
||
if err:
|
||
return err
|
||
name = request.files["file"].filename
|
||
try:
|
||
rep = analyze_file(p, name)
|
||
return jsonify({"tool": "inspect/file", "credits": COSTS["inspect/file"], "result": rep})
|
||
finally:
|
||
shutil.rmtree(os.path.dirname(p), ignore_errors=True)
|
||
|
||
@app.route("/api/steg/extract", methods=["POST"])
|
||
@metered("steg/extract")
|
||
def steg_extract_route():
|
||
p, err = save_upload()
|
||
if err:
|
||
return err
|
||
name = request.files["file"].filename
|
||
passphrase = (request.form.get("passphrase") or "")
|
||
try:
|
||
rep = steg_extract(p, name, passphrase)
|
||
return jsonify({"tool": "steg/extract", "credits": COSTS["steg/extract"], "result": rep})
|
||
finally:
|
||
shutil.rmtree(os.path.dirname(p), ignore_errors=True)
|
||
|
||
@app.route("/api/steg/crack", methods=["POST"])
|
||
@metered("steg/crack")
|
||
def steg_crack_route():
|
||
p, err = save_upload()
|
||
if err:
|
||
return err
|
||
try:
|
||
rep = steg_crack(p, request.files["file"].filename)
|
||
return jsonify({"tool": "steg/crack", "credits": COSTS["steg/crack"], "result": rep})
|
||
finally:
|
||
shutil.rmtree(os.path.dirname(p), ignore_errors=True)
|
||
|
||
@app.route("/api/forensics/disk", methods=["POST"])
|
||
@metered("forensics/disk")
|
||
def forensics_route():
|
||
p, err = save_upload()
|
||
if err:
|
||
return err
|
||
try:
|
||
rep = forensics_disk(p, request.files["file"].filename)
|
||
return jsonify({"tool": "forensics/disk", "credits": COSTS["forensics/disk"], "result": rep})
|
||
finally:
|
||
shutil.rmtree(os.path.dirname(p), ignore_errors=True)
|
||
|
||
# ----------------------------- recon (Kali) -----------------------------
|
||
RECON_MAP = {
|
||
"nmap": {"params": lambda t: {"target": t, "scan_type": "-sV"}},
|
||
"subfinder": {"params": lambda t: {"domain": t}},
|
||
"nuclei": {"params": lambda t: {"target": t, "timeout": 240}},
|
||
"theharvester": {"params": None},
|
||
"dnsrecon": {"params": None},
|
||
}
|
||
|
||
@app.route("/api/recon/<tool>", methods=["POST"])
|
||
def recon_route(tool):
|
||
if tool not in RECON_MAP:
|
||
return jsonify({"error": "unknown recon tool", "available": list(RECON_MAP.keys())}), 400
|
||
d = request.json or {}
|
||
target = clean_target(d.get("target", ""))
|
||
if not target:
|
||
return jsonify({"error": "valid target required"}), 400
|
||
# manual metering (cost depends on dynamic tool)
|
||
row, err = auth(gate=True)
|
||
if not row:
|
||
return err
|
||
cost = COSTS["recon/" + tool]
|
||
if not row["is_admin"] and row["credits"] < cost:
|
||
return jsonify({"error": f"insufficient credits ({row['credits']} < {cost}). Buy at /api/order"}), 402
|
||
if tool in ("theharvester", "dnsrecon"):
|
||
# use Kali shell with sanitized fixed command
|
||
if tool == "theharvester":
|
||
cmd = f"theHarvester -d {target} -b all -l 100 2>&1 | head -80"
|
||
else:
|
||
cmd = f"dnsrecon -d {target} 2>&1 | head -100"
|
||
res = kali_call("shell", {"command": cmd, "timeout": 240})
|
||
out = res.get("stdout", "") if isinstance(res, dict) else str(res)
|
||
else:
|
||
res = kali_call(tool, RECON_MAP[tool]["params"](target), timeout=360)
|
||
out = res.get("stdout", "") if isinstance(res, dict) else str(res)
|
||
log_usage(row["api_key"], f"recon/{tool}", target, cost)
|
||
return jsonify({"tool": f"recon/{tool}", "target": target,
|
||
"credits": cost, "result": out[:8000]})
|
||
|
||
# ----------------------------- MCP (agent-native, JSON-RPC 2.0) -----------------------------
|
||
MCP_TOOLS = {
|
||
"lynx_signup": {"email": "string"},
|
||
"lynx_me": {"api_key": "string"},
|
||
"lynx_order": {"api_key": "string", "usd": "number"},
|
||
"lynx_inspect_file": {"api_key": "string", "filename": "string", "content_base64": "string"},
|
||
"lynx_steg_extract": {"api_key": "string", "filename": "string", "content_base64": "string", "passphrase": "string"},
|
||
"lynx_recon": {"api_key": "string", "tool": "string", "target": "string"},
|
||
}
|
||
MCP_TOOL_DESCS = {
|
||
"lynx_signup": "Create a no-KYC API key with an email. Returns sk-lynx-... key (0 credits).",
|
||
"lynx_me": "Check credits + usage for a key.",
|
||
"lynx_order": "Create a BTCPay invoice for credits. usd in {2,5,10,20}. Returns a checkout_link to pay in bitcoin.",
|
||
"lynx_inspect_file": "Inspect a file (hashes, entropy, strings, metadata, PE/PDF/Office analysis). Pass file bytes as base64.",
|
||
"lynx_steg_extract": "Extract hidden data (steghide + zsteg LSB). Optional passphrase.",
|
||
"lynx_recon": "Run recon against an external target. tool in {nmap,subfinder,nuclei,theharvester,dnsrecon}.",
|
||
}
|
||
|
||
def _mcp_tool_call(name, args):
|
||
key = args.get("api_key", "")
|
||
if name == "lynx_signup":
|
||
email = (args.get("email") or "").strip().lower()
|
||
k = new_key()
|
||
try:
|
||
db().execute("INSERT INTO users (email, api_key, credits, created_at) VALUES (?,?,?,?)", (email, k, 0, int(time.time())))
|
||
db().commit()
|
||
except sqlite3.IntegrityError:
|
||
k = db().execute("SELECT api_key FROM users WHERE email=?", (email,)).fetchone()["api_key"]
|
||
return {"api_key": k, "credits": 0}
|
||
if name == "lynx_me":
|
||
row = db().execute("SELECT * FROM users WHERE api_key=?", (key,)).fetchone()
|
||
if not row:
|
||
return {"error": "invalid api_key"}
|
||
return {"email": row["email"], "credits": row["credits"], "total_calls": row["total_calls"]}
|
||
if name == "lynx_order":
|
||
usd = float(args.get("usd", 5))
|
||
if usd not in TIERS:
|
||
return {"error": "usd in " + str(sorted(TIERS.keys()))}
|
||
row = db().execute("SELECT * FROM users WHERE api_key=?", (key,)).fetchone()
|
||
if not row:
|
||
return {"error": "invalid api_key"}
|
||
credits = TIERS[usd]
|
||
order_id = "lynx-" + secrets.token_hex(8)
|
||
inv = {"amount": str(usd), "currency": "USD", "checkout": {"redirectURL": f"https://{PUBLIC_DOMAIN}/api/order/{order_id}"},
|
||
"metadata": {"orderId": order_id, "api_key": key, "credits": credits}}
|
||
req = urllib.request.Request(f"{BTCPAY_URL}/api/v1/stores/{BTCPAY_STORE}/invoices",
|
||
data=json.dumps(inv).encode(), headers={"Content-Type": "application/json", "Authorization": "token " + BTCPAY_KEY})
|
||
try:
|
||
resp = urllib.request.urlopen(req, timeout=20, context=_btc_ctx)
|
||
inv_body = json.loads(resp.read().decode())
|
||
except Exception as e:
|
||
return {"error": f"invoice failed: {e}"}
|
||
db().execute("INSERT INTO orders (order_id, api_key, invoice_id, credits, created_at) VALUES (?,?,?,?,?)",
|
||
(order_id, key, inv_body.get("id"), credits, int(time.time())))
|
||
db().commit()
|
||
return {"order_id": order_id, "credits": credits, "usd": usd,
|
||
"checkout_link": inv_body.get("checkoutLink", "").replace("https://10.30.20.140", BTCPAY_PUBLIC)}
|
||
# file tools (base64 content)
|
||
if name in ("lynx_inspect_file", "lynx_steg_extract"):
|
||
row = db().execute("SELECT * FROM users WHERE api_key=?", (key,)).fetchone()
|
||
if not row:
|
||
return {"error": "invalid api_key"}
|
||
tool = "inspect/file" if name == "lynx_inspect_file" else "steg/extract"
|
||
cost = COSTS[tool]
|
||
if not row["is_admin"] and row["credits"] < cost:
|
||
return {"error": f"insufficient credits ({row['credits']} < {cost})"}
|
||
try:
|
||
content = base64.b64decode(args.get("content_base64", ""))
|
||
except Exception:
|
||
return {"error": "invalid content_base64"}
|
||
d = os.path.join(WORK_DIR, uuid.uuid4().hex)
|
||
os.makedirs(d, exist_ok=True)
|
||
fn = os.path.basename(args.get("filename", "upload.bin")) or "upload.bin"
|
||
p = os.path.join(d, fn)
|
||
with open(p, "wb") as f:
|
||
f.write(content)
|
||
try:
|
||
if name == "lynx_inspect_file":
|
||
rep = analyze_file(p, fn)
|
||
else:
|
||
rep = steg_extract(p, fn, args.get("passphrase", ""))
|
||
finally:
|
||
shutil.rmtree(d, ignore_errors=True)
|
||
log_usage(key, tool, fn, cost)
|
||
return {"credits_used": cost, "result": rep}
|
||
if name == "lynx_recon":
|
||
row = db().execute("SELECT * FROM users WHERE api_key=?", (key,)).fetchone()
|
||
if not row:
|
||
return {"error": "invalid api_key"}
|
||
tool = args.get("tool", "")
|
||
if tool not in RECON_MAP:
|
||
return {"error": "tool in " + str(list(RECON_MAP.keys()))}
|
||
target = clean_target(args.get("target", ""))
|
||
if not target:
|
||
return {"error": "valid target required"}
|
||
cost = COSTS["recon/" + tool]
|
||
if not row["is_admin"] and row["credits"] < cost:
|
||
return {"error": f"insufficient credits ({row['credits']} < {cost})"}
|
||
if tool in ("theharvester", "dnsrecon"):
|
||
cmd = f"theHarvester -d {target} -b all -l 100 2>&1 | head -80" if tool == "theharvester" else f"dnsrecon -d {target} 2>&1 | head -100"
|
||
res = kali_call("shell", {"command": cmd, "timeout": 240})
|
||
out = res.get("stdout", "") if isinstance(res, dict) else str(res)
|
||
else:
|
||
res = kali_call(tool, RECON_MAP[tool]["params"](target), timeout=360)
|
||
out = res.get("stdout", "") if isinstance(res, dict) else str(res)
|
||
log_usage(key, "recon/" + tool, target, cost)
|
||
return {"credits_used": cost, "result": out[:8000]}
|
||
return {"error": "unknown tool"}
|
||
|
||
@app.route("/mcp", methods=["POST"])
|
||
def mcp_endpoint():
|
||
req_json = request.get_json(silent=True)
|
||
if not req_json:
|
||
return jsonify({"error": {"code": -32700, "message": "invalid JSON"}}), 400
|
||
method = req_json.get("method")
|
||
rid = req_json.get("id")
|
||
params = req_json.get("params", {}) or {}
|
||
if method == "initialize":
|
||
return jsonify({"jsonrpc": "2.0", "id": rid, "result": {
|
||
"protocolVersion": "2024-11-05",
|
||
"capabilities": {"tools": {}},
|
||
"serverInfo": {"name": "lynx", "version": "1.0.0"}}})
|
||
if method == "ping":
|
||
return jsonify({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||
if method == "notifications/initialized":
|
||
return jsonify({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||
if method == "tools/list":
|
||
tools = [{"name": n, "description": MCP_TOOL_DESCS[n],
|
||
"inputSchema": {"type": "object", "properties": {k: {"type": v} for k, v in MCP_TOOLS[n].items()},
|
||
"required": list(MCP_TOOLS[n].keys())}}
|
||
for n in MCP_TOOLS]
|
||
return jsonify({"jsonrpc": "2.0", "id": rid, "result": {"tools": tools}})
|
||
if method == "tools/call":
|
||
name = params.get("name", "")
|
||
if name not in MCP_TOOLS:
|
||
return jsonify({"jsonrpc": "2.0", "id": rid, "error": {"code": -32000, "message": "unknown tool"}})
|
||
args = params.get("arguments", {}) or {}
|
||
try:
|
||
result = _mcp_tool_call(name, args)
|
||
except Exception as e:
|
||
return jsonify({"jsonrpc": "2.0", "id": rid, "error": {"code": -32603, "message": str(e)}})
|
||
return jsonify({"jsonrpc": "2.0", "id": rid, "result": {"content": [{"type": "text", "text": json.dumps(result)}]}})
|
||
return jsonify({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "method not found"}})
|
||
|
||
# ----------------------------- admin -----------------------------
|
||
@app.route("/admin")
|
||
def admin():
|
||
key = get_key()
|
||
if key != ADMIN_KEY:
|
||
return jsonify({"error": "admin only"}), 403
|
||
users = [dict(r) for r in db().execute("SELECT * FROM users ORDER BY id DESC LIMIT 50").fetchall()]
|
||
return jsonify({"admin": True, "users": users, "admin_key": ADMIN_KEY})
|
||
|
||
@app.route("/admin/revoke", methods=["POST"])
|
||
def admin_revoke():
|
||
if get_key() != ADMIN_KEY:
|
||
return jsonify({"error": "admin only"}), 403
|
||
key = (request.json or {}).get("api_key")
|
||
db().execute("DELETE FROM users WHERE api_key=?", (key,))
|
||
db().commit()
|
||
return jsonify({"revoked": key})
|
||
|
||
# ----------------------------- docs -----------------------------
|
||
@app.route("/openapi.json")
|
||
def openapi():
|
||
spec = {"openapi": "3.0.0", "info": {"title": "LYNX", "version": "1.0.0",
|
||
"description": "No-KYC inspection, steg, forensics, and recon API — paid in Bitcoin."},
|
||
"servers": [{"url": f"https://{PUBLIC_DOMAIN}"}]}
|
||
return jsonify(spec)
|
||
|
||
@app.route("/docs")
|
||
def docs():
|
||
return Response(render_docs(), mimetype="text/html")
|
||
|
||
@app.route("/")
|
||
def home():
|
||
return Response(render_home(), mimetype="text/html")
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# HTML (spooky dark landing + docs)
|
||
# ----------------------------------------------------------------------------
|
||
def render_home():
|
||
return _HTML_HEAD + HOME_BODY + _HTML_FOOT
|
||
|
||
def render_docs():
|
||
return _HTML_HEAD + DOCS_BODY + _HTML_FOOT
|
||
|
||
_HTML_HEAD = """<!doctype html><html lang=en><head><meta charset=utf-8>
|
||
<meta name=viewport content="width=device-width,initial-scale=1">
|
||
<meta name=description content="LYNX — no-KYC file inspection, steganography, forensics, and recon API. Paid in Bitcoin.">
|
||
<meta property=og:title content="LYNX — the sharp-eyed inspector">
|
||
<meta property=og:description content="Inspect files, decode hidden data, carve forensics, run recon — pay in sats. No KYC.">
|
||
<meta property=og:type content=website>
|
||
<meta property=og:url content="https://lynx.thetempleofdoom.com/">
|
||
<title>LYNX — the sharp-eyed inspector</title>
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%94%AD%3C/text%3E%3C/svg%3E">
|
||
<style>
|
||
:root{--bg:#05070d;--panel:rgba(13,20,38,.72);--line:rgba(56,89,152,.35);--txt:#e8f0fb;--dim:#8fa3c8;--acc:#22d3ee;--acc2:#8b5cf6;--good:#34d399;--blood:#e11d48}
|
||
*{box-sizing:border-box;margin:0;padding:0}html{scroll-behavior:smooth}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--txt);line-height:1.65;-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||
#bg{position:fixed;inset:0;z-index:0;pointer-events:none}
|
||
.content{position:relative;z-index:1}.wrap{max-width:1080px;margin:0 auto;padding:0 24px}
|
||
nav{display:flex;justify-content:space-between;align-items:center;padding:22px 0}
|
||
.logo{font-weight:900;font-size:1.5rem;letter-spacing:2px}.logo span{background:linear-gradient(90deg,var(--acc),var(--acc2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||
nav a{color:var(--dim);text-decoration:none;margin-left:18px;font-size:.9rem}nav a:hover{color:var(--txt)}
|
||
.hero{padding:80px 0 40px;text-align:center}
|
||
.hero h1{font-size:3.4rem;font-weight:900;letter-spacing:4px;background:linear-gradient(90deg,var(--acc),var(--acc2),#f472b6);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||
.hero p.tag{color:var(--dim);font-size:1.25rem;margin-top:12px}
|
||
.stats{display:flex;gap:14px;justify-content:center;flex-wrap:wrap;margin:28px 0}
|
||
.stat{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 22px;min-width:120px}
|
||
.stat b{font-size:1.4rem;color:var(--acc)}.stat span{display:block;color:var(--dim);font-size:.75rem;letter-spacing:1px;text-transform:uppercase}
|
||
h2{font-size:1.8rem;margin:40px 0 16px;color:var(--txt)}h2 em{color:var(--acc);font-style:normal}
|
||
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:22px;margin:14px 0}
|
||
pre{background:#0a0f1c;border:1px solid var(--line);border-radius:10px;padding:14px;overflow-x:auto;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.82rem;color:#a5f3fc;white-space:pre-wrap}
|
||
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
||
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:.9rem}
|
||
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line)}
|
||
th{color:var(--acc);text-transform:uppercase;font-size:.75rem;letter-spacing:1px}
|
||
.badge{display:inline-block;background:rgba(139,92,246,.2);color:#c4b5fd;border:1px solid var(--acc2);border-radius:20px;padding:2px 12px;font-size:.72rem;letter-spacing:1px;text-transform:uppercase}
|
||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px}
|
||
.grid .card{margin:0}.grid h3{color:var(--acc);margin-bottom:8px;font-size:1.05rem}
|
||
.cta{background:linear-gradient(90deg,var(--acc),var(--acc2));border:none;color:#04121a;font-weight:800;font-size:1rem;padding:14px 30px;border-radius:10px;cursor:pointer;letter-spacing:1px}
|
||
.cta:hover{filter:brightness(1.1)}footer{padding:40px 0 60px;text-align:center;color:var(--dim);font-size:.8rem}
|
||
footer a{color:var(--dim)}
|
||
@media(max-width:640px){.hero h1{font-size:2.2rem}}
|
||
</style>
|
||
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="7621b140-1457-4ff1-a3f1-8f48205db32d"></script>
|
||
</head><body>
|
||
<div id=bg><canvas></canvas></div><div class=content><div class=wrap>"""
|
||
|
||
HOME_BODY = """<nav><div class=logo><span>LYNX</span></div>
|
||
<div><a href="/docs">API docs</a><a href="/openapi.json">OpenAPI</a><a href="/api/pricing">Pricing</a></div></nav>
|
||
<div class=hero><h1>LYNX</h1><p class=tag>the sharp-eyed inspector — see what's hidden.</p>
|
||
<div class=stats><div class=stat><b id=s_users>…</b><span>agents</span></div><div class=stat><b id=s_calls>…</b><span>inspections</span></div><div class=stat><b>20</b><span>tools</span></div><div class=stat><b>no-KYC</b><span>bitcoin</span></div></div>
|
||
<p class=tag style="font-size:1rem">Inspect files · decode steganography · carve forensics · run recon — paid in sats.</p></div>
|
||
|
||
<h2>How it <em>works</em></h2>
|
||
<div class=card><pre># 1. get a key (no KYC — just an email)
|
||
curl -X POST https://lynx.thetempleofdoom.com/api/signup -H 'Content-Type: application/json' -d '{"email":"you@proton.me"}'
|
||
|
||
# 2. buy credits (bitcoin)
|
||
curl -X POST https://lynx.thetempleofdoom.com/api/order -H 'Content-Type: application/json' -d '{"usd":5}'
|
||
|
||
# 3. inspect a file
|
||
curl -X POST https://lynx.thetempleofdoom.com/api/inspect/file -H 'X-API-Key: sk-lynx-…' -F 'file=@suspicious.pdf'</pre></div>
|
||
|
||
<h2>The <em>toolchain</em></h2>
|
||
<div class=grid>
|
||
<div class=card><h3>🔬 File inspection</h3><p>hashes (md5/sha1/sha256/ssdeep), entropy, strings, EXIF metadata, PE analysis, Office macro scan, PDF object analysis, binwalk firmware signatures.</p><span class=badge>1 credit</span></div>
|
||
<div class=card><h3>🕵️ Steganography</h3><p>steghide extraction, zsteg LSB detection, stegcracker passphrase recovery — decode data hidden in images and files.</p><span class=badge>2–4 credits</span></div>
|
||
<div class=card><h3>🧬 Forensics</h3><p>Sleuth Kit disk carving (mmls/fls/fsstat/tsk_recover), volatility3 memory analysis.</p><span class=badge>3 credits</span></div>
|
||
<div class=card><h3>📡 Recon</h3><p>nmap, subfinder, nuclei, theHarvester, dnsrecon — isolated egress, never touches the operator network.</p><span class=badge>2–4 credits</span></div>
|
||
</div>
|
||
|
||
<h2>Pricing — <em>no subscription</em></h2>
|
||
<div class=card><table><tr><th>USD</th><th>credits</th></tr>
|
||
<tr><td>$2</td><td>20</td></tr><tr><td>$5</td><td>60</td></tr><tr><td>$10</td><td>150</td></tr><tr><td>$20</td><td>400</td></tr></table>
|
||
<p style="color:var(--dim);font-size:.85rem">Credits never expire. No KYC, no tracking, no bullshit. Pay on-chain or via Lightning.</p></div>
|
||
|
||
<h2>Built for <em>agents</em></h2>
|
||
<div class=card><p>Every tool is machine-callable. Pull <code>/openapi.json</code> for a full spec, or drive LYNX straight from your agent's MCP client. Programmatic keys, Bitcoin-settled metered billing — the API is the product.</p></div>
|
||
<p style="text-align:center;margin:30px 0"><a class=cta href="/docs">Read the API docs →</a></p>
|
||
<footer>LYNX · the sharp-eyed inspector · <a href="https://buymeacoffee.com/r26xrthzttg">☕ support the lab</a><br>operated from the temple · <a href="/docs">docs</a> · <a href="/openapi.json">openapi</a></footer>"""
|
||
|
||
DOCS_BODY = """<nav><div class=logo><span>LYNX</span></div><div><a href="/">home</a><a href="/openapi.json">openapi</a></div></nav>
|
||
<h2>API <em>reference</em></h2>
|
||
<p style="color:var(--dim)">All metered endpoints accept the key via <code>X-API-Key</code> header or <code>?api_key=</code> query param.</p>
|
||
|
||
<div class=card><h3>POST /api/signup</h3><p>No-KYC key. Returns <code>api_key</code> (0 credits).</p>
|
||
<pre>curl -X POST https://lynx.thetempleofdoom.com/api/signup -H 'Content-Type: application/json' -d '{"email":"you@proton.me"}'</pre></div>
|
||
|
||
<div class=card><h3>POST /api/order</h3><p>Create a BTCPay invoice. <code>usd</code> ∈ {2,5,10,20}. Returns <code>checkout_link</code>.</p>
|
||
<pre>curl -X POST https://lynx.thetempleofdoom.com/api/order -H 'X-API-Key: sk-lynx-…' -H 'Content-Type: application/json' -d '{"usd":5}'</pre></div>
|
||
|
||
<div class=card><h3>GET /api/me</h3><p>Credits + usage for your key.</p>
|
||
<pre>curl https://lynx.thetempleofdoom.com/api/me -H 'X-API-Key: sk-lynx-…'</pre></div>
|
||
|
||
<div class=card><h3>POST /api/inspect/file</h3><p>multipart upload → full inspection report. <span class=badge>1 credit</span></p>
|
||
<pre>curl -X POST https://lynx.thetempleofdoom.com/api/inspect/file -H 'X-API-Key: sk-lynx-…' -F 'file=@sample.pdf'</pre></div>
|
||
|
||
<div class=card><h3>POST /api/steg/extract</h3><p>steghide + zsteg extraction. Optional <code>passphrase</code> form field. <span class=badge>2 credits</span></p>
|
||
<pre>curl -X POST https://lynx.thetempleofdoom.com/api/steg/extract -H 'X-API-Key: sk-lynx-…' -F 'file=@hidden.png' -F 'passphrase=secret'</pre></div>
|
||
|
||
<div class=card><h3>POST /api/steg/crack</h3><p>stegcracker passphrase recovery. <span class=badge>4 credits</span></p>
|
||
<pre>curl -X POST https://lynx.thetempleofdoom.com/api/steg/crack -H 'X-API-Key: sk-lynx-…' -F 'file=@hidden.jpg'</pre></div>
|
||
|
||
<div class=card><h3>POST /api/forensics/disk</h3><p>Sleuth Kit on a disk image (mmls/fsstat/fls). <span class=badge>3 credits</span></p>
|
||
<pre>curl -X POST https://lynx.thetempleofdoom.com/api/forensics/disk -H 'X-API-Key: sk-lynx-…' -F 'file=@disk.img'</pre></div>
|
||
|
||
<div class=card><h3>POST /api/recon/<tool></h3><p><code>nmap</code>, <code>subfinder</code>, <code>nuclei</code>, <code>theharvester</code>, <code>dnsrecon</code>. Body <code>{"target":"example.com"}</code>.</p>
|
||
<pre>curl -X POST https://lynx.thetempleofdoom.com/api/recon/nmap -H 'X-API-Key: sk-lynx-…' -H 'Content-Type: application/json' -d '{"target":"example.com"}'</pre></div>
|
||
|
||
<footer>LYNX · the sharp-eyed inspector · <a href="https://buymeacoffee.com/r26xrthzttg">☕ support</a> · <a href="/">home</a></footer>"""
|
||
|
||
_HTML_FOOT = """</div></div>
|
||
<script>const A='';
|
||
fetch(A+'/stats').then(r=>r.json()).then(d=>{document.getElementById('s_users').textContent=d.users;document.getElementById('s_calls').textContent=d.total_calls}).catch(()=>{});
|
||
// aurora particles
|
||
(function(){const c=document.querySelector('#bg canvas');const x=c.getContext('2d');let w,h,p=[];
|
||
function R(){w=c.width=innerWidth;h=c.height=innerHeight}
|
||
R();addEventListener('resize',R);
|
||
for(let i=0;i<70;i++)p.push({x:Math.random()*w,y:Math.random()*h,r:Math.random()*2+.5,v:Math.random()*.4+.1,a:Math.random()*.4+.1});
|
||
function D(){x.clearRect(0,0,w,h);for(const q of p){q.y-=q.v;if(q.y<0){q.y=h;q.x=Math.random()*w}x.beginPath();x.arc(q.x,q.y,q.r,0,7);x.fillStyle='rgba(34,211,238,'+q.a+')';x.fill()}requestAnimationFrame(D)}D()})();
|
||
</script></body></html>"""
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Bootstrap (runs at import — gunicorn needs this, not just __main__)
|
||
# ----------------------------------------------------------------------------
|
||
init_db()
|
||
resolve_tools()
|
||
os.makedirs(WORK_DIR, exist_ok=True)
|
||
if not os.path.exists(STEG_WORDLIST):
|
||
with open(STEG_WORDLIST, "w") as f:
|
||
f.write("\n".join(["password","123456","letmein","secret","admin","root","hidden","steg","changeme","dragon","monkey","qwerty","abc123","iloveyou","trustno1","hunter2","welcome","shadow","baseball","football","superman","batman","matrix","pokemon","starwars","joshua","master","passw0rd","password1","default","guest"]) + "\n")
|
||
|
||
# Seed admin user (is_admin bypasses credit gate on metered tools) — use direct conn (no app context at import)
|
||
_c = sqlite3.connect(DB_PATH, timeout=15)
|
||
if not _c.execute("SELECT 1 FROM users WHERE api_key=?", (ADMIN_KEY,)).fetchone():
|
||
_c.execute("INSERT INTO users (email, api_key, credits, is_admin, created_at) VALUES (?,?,?,?,?)",
|
||
("admin@lynx.local", ADMIN_KEY, 999999, 1, int(time.time())))
|
||
_c.commit()
|
||
_c.close()
|
||
|
||
if __name__ == "__main__":
|
||
app.run(host="0.0.0.0", port=PORT)
|