Files
rigel/app.py

394 lines
20 KiB
Python

#!/usr/bin/env python3
"""
Rigel — no-KYC SOCKS5 proxy shop.
Sells access to residential/mobile/datacenter proxies. Bitcoin via BTCPay.
Auth: no-KYC (username + email + password, no ID). Captcha-lite (math challenge).
Inventory: proxied through gost SOCKS5 frontends with per-user auth.
"""
import os, sqlite3, json, hmac, hashlib, time, uuid, secrets
from datetime import datetime, timedelta
from flask import Flask, request, jsonify, g, render_template_string, redirect, session
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
DB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "rigel.db")
# ── config ──────────────────────────────────────────────────────────────
# Proxy inventory: each location is a gost frontend. We issue per-user creds.
# The actual upstream SOCKS5 endpoints (Nord CTs) are hidden behind a gost
# auth layer so customers never see the raw CT IPs.
LOCATIONS = {
"tokyo": {"name": "Tokyo, JP", "upstream": "10.30.20.154:1080", "type": "datacenter"},
"london": {"name": "London, UK", "upstream": "10.30.20.71:1080", "type": "datacenter"},
"sydney": {"name": "Sydney, AU", "upstream": "10.30.20.189:1080", "type": "datacenter"},
# IPRoyal residential + mobile are added when creds are provisioned
"residential": {"name": "Residential (rotating)", "upstream": "geo.iproyal.com:12321", "type": "residential"},
"mobile": {"name": "Mobile 4G (sticky)", "upstream": "us.4g.iproyal.com:7001", "type": "mobile"},
}
PLANS = {
"day": {"label": "1 Day", "hours": 24, "sats": 8000},
"week": {"label": "1 Week", "hours": 168, "sats": 45000},
"month": {"label": "1 Month", "hours": 720, "sats": 150000},
}
BTCPAY_URL = os.environ.get("BTCPAY_URL", "https://10.30.20.140")
BTCPAY_STORE = os.environ.get("BTCPAY_STORE_ID", "")
BTCPAY_TOKEN = os.environ.get("BTCPAY_API_KEY", "")
# Public SOCKS5 endpoint customers connect to (authenticated frontend on CT158)
PROXY_HOST = os.environ.get("PROXY_PUBLIC_HOST", "10.30.20.116")
PROXY_PORT = os.environ.get("PROXY_PUBLIC_PORT", "1081")
def db():
con = getattr(g, "_db", None)
if con is None:
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
g._db = con
return con
@app.teardown_appcontext
def close_db(exc):
con = getattr(g, "_db", None)
if con is not None:
con.close()
def init_db():
con = sqlite3.connect(DB)
con.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT,
password_hash TEXT NOT NULL,
balance_sats INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
location TEXT NOT NULL,
plan TEXT NOT NULL,
sats_paid INTEGER NOT NULL,
starts_at TEXT,
expires_at TEXT,
proxy_user TEXT,
proxy_pass TEXT,
status TEXT DEFAULT 'pending', -- pending|active|expired
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
btcpay_invoice_id TEXT,
amount_sats INTEGER,
status TEXT DEFAULT 'new', -- new|paid|expired
created_at TEXT DEFAULT (datetime('now'))
);
""")
con.commit()
con.close()
def hash_pw(pw, salt=None):
salt = salt or secrets.token_hex(16)
return salt + ":" + hmac.new(salt.encode(), pw.encode(), hashlib.sha256).hexdigest()
def check_pw(pw, stored):
salt, digest = stored.split(":", 1)
return hmac.new(salt.encode(), pw.encode(), hashlib.sha256).hexdigest() == digest
def current_user():
uid = session.get("uid")
if not uid:
return None
return db().execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
# ── routes ──────────────────────────────────────────────────────────────
@app.route("/")
def index():
user = current_user()
if not user:
return render_template_string(AUTH_HTML, mode="login", error="")
return render_template_string(DASH_HTML, user=user, locations=LOCATIONS, plans=PLANS,
proxy_host=PROXY_HOST, proxy_port=PROXY_PORT)
@app.route("/health")
def health():
return jsonify({"status": "ok", "service": "rigel"})
@app.route("/api/register", methods=["POST"])
def register():
d = request.get_json(force=True) if request.is_json else request.form
username = (d.get("username") or "").strip()
email = (d.get("email") or "").strip()
pw = d.get("password") or ""
captcha = d.get("captcha") or ""
if not username or not pw or len(username) > 25 or len(pw) < 6:
return jsonify({"error": "username + password (6+ chars) required"}), 400
if not check_captcha(captcha):
return jsonify({"error": "wrong captcha"}), 400
con = db()
try:
con.execute("INSERT INTO users (username, email, password_hash) VALUES (?,?,?)",
(username, email, hash_pw(pw)))
con.commit()
except sqlite3.IntegrityError:
return jsonify({"error": "username taken"}), 409
return jsonify({"ok": True})
@app.route("/api/login", methods=["POST"])
def login():
d = request.get_json(force=True) if request.is_json else request.form
username = (d.get("username") or "").strip()
pw = d.get("password") or ""
captcha = d.get("captcha") or ""
if not check_captcha(captcha):
return jsonify({"error": "wrong captcha"}), 400
user = db().execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
if not user or not check_pw(pw, user["password_hash"]):
return jsonify({"error": "bad credentials"}), 401
session["uid"] = user["id"]
return jsonify({"ok": True})
@app.route("/api/logout", methods=["POST"])
def logout():
session.pop("uid", None)
return jsonify({"ok": True})
# Captcha-lite: math challenge rendered as text (no external captcha service)
@app.route("/api/captcha")
def captcha():
a, b = secrets.randbelow(9) + 1, secrets.randbelow(9) + 1
session["captcha"] = str(a + b)
return jsonify({"question": f"What is {a} + {b}?", "id": "c1"})
def check_captcha(ans):
return (ans or "").strip() == session.get("captcha", "")
@app.route("/api/buy", methods=["POST"])
def buy():
user = current_user()
if not user:
return jsonify({"error": "login required"}), 401
d = request.get_json(force=True) if request.is_json else request.form
location = d.get("location", "")
plan = d.get("plan", "")
if location not in LOCATIONS or plan not in PLANS:
return jsonify({"error": "bad location/plan"}), 400
sats = PLANS[plan]["sats"]
# create BTCPay invoice
invoice_id, checkout = create_btcpay_invoice(user, sats, f"Rigel {LOCATIONS[location]['name']} {PLANS[plan]['label']}")
con = db()
cur = con.execute("INSERT INTO subscriptions (user_id, location, plan, sats_paid, proxy_user) VALUES (?,?,?,?,?)",
(user["id"], location, plan, sats, f"u{user['id']}{secrets.token_hex(4)}"))
con.execute("INSERT INTO invoices (user_id, btcpay_invoice_id, amount_sats) VALUES (?,?,?)",
(user["id"], invoice_id, sats))
con.commit()
return jsonify({"ok": True, "invoice_id": invoice_id, "checkout": checkout, "sats": sats})
def create_btcpay_invoice(user, sats, desc):
if not BTCPAY_STORE or not BTCPAY_TOKEN:
# fallback: no BTCPay configured — return a placeholder
return "local-" + uuid.uuid4().hex[:12], None
import urllib.request, ssl as _ssl
ctx = _ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = _ssl.CERT_NONE
body = json.dumps({"amount": str(sats/1e8), "currency": "BTC", "metadata": {"desc": desc, "user": user["username"]}})
req = urllib.request.Request(f"{BTCPAY_URL}/api/v1/stores/{BTCPAY_STORE}/invoices",
data=body.encode(), headers={"Authorization": f"token {BTCPAY_TOKEN}", "Content-Type": "application/json"}, method="POST")
r = urllib.request.urlopen(req, timeout=20, context=ctx)
inv = json.loads(r.read())
return inv["id"], inv.get("checkoutLink")
@app.route("/api/subscriptions")
def subscriptions():
user = current_user()
if not user:
return jsonify({"error": "login required"}), 401
subs = db().execute("SELECT * FROM subscriptions WHERE user_id=? ORDER BY id DESC", (user["id"],)).fetchall()
return jsonify([dict(s) for s in subs])
@app.route("/api/status")
def status():
user = current_user()
if not user:
return jsonify({"authenticated": False})
return jsonify({"authenticated": True, "username": user["username"], "balance_sats": user["balance_sats"]})
# ── BTCPay webhook: activate subscription on payment ─────────────────────
WEBHOOK_SECRET = os.environ.get("BTCPAY_WEBHOOK_SECRET", "")
@app.route("/api/btcpay/webhook", methods=["POST"])
def btcpay_webhook():
# optional HMAC verification (raw body)
if WEBHOOK_SECRET:
raw = request.get_data()
sig = request.headers.get("BTCPay-Sig", "")
expected = "sha256=" + hmac.new(WEBHOOK_SECRET.encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return jsonify({"error": "bad signature"}), 401
payload = request.get_json(force=True, silent=True) or {}
etype = payload.get("type", "")
invoice_id = payload.get("invoiceId", "")
status = payload.get("status", "")
if etype in ("InvoiceSettled", "InvoiceReceivedPayment") and invoice_id:
# find matching invoice -> subscription
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
inv = con.execute("SELECT * FROM invoices WHERE btcpay_invoice_id=? AND status!='paid'", (invoice_id,)).fetchone()
if inv:
con.execute("UPDATE invoices SET status='paid' WHERE btcpay_invoice_id=?", (invoice_id,))
# activate subscription + issue proxy creds
sub = con.execute("SELECT * FROM subscriptions WHERE user_id=? AND status='pending' ORDER BY id DESC LIMIT 1",
(inv["user_id"],)).fetchone()
if sub:
proxy_user = sub["proxy_user"] or f"u{inv['user_id']}{secrets.token_hex(4)}"
proxy_pass = secrets.token_hex(12)
hours = PLANS.get(sub["plan"], {}).get("hours", 24)
now = datetime.utcnow()
expires = now + timedelta(hours=hours)
con.execute("""UPDATE subscriptions SET status='active', proxy_user=?, proxy_pass=?,
starts_at=?, expires_at=? WHERE id=?""",
(proxy_user, proxy_pass, now.isoformat(), expires.isoformat(), sub["id"]))
# provision the proxy auth upstream (gost) — best-effort
provision_proxy(sub["location"], proxy_user, proxy_pass)
con.commit()
con.close()
return jsonify({"ok": True})
def provision_proxy(location, user, pw):
"""No per-node writes needed: the authenticated SOCKS5 frontend
(rigel-proxy.service on CT158) validates every connection live against the
subscriptions table, so persisting the row IS the provisioning step.
The upstream exits are dumb unauthenticated relays on the LAN."""
up = LOCATIONS.get(location, {}).get("upstream", "?")
app.logger.info(f"provisioned {location} (upstream {up}) for {user}")
# ── templates ───────────────────────────────────────────────────────────
AUTH_HTML = r"""
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Rigel — SOCKS5 Proxies</title>
<style>
:root{--bg:#0a0e1a;--card:#12182b;--accent:#4f8cff;--text:#e6ebf5;--muted:#8b95ad;--ok:#2ecc71}
*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{background:var(--card);border:1px solid #1e2742;border-radius:14px;padding:34px;width:100%;max-width:400px;box-shadow:0 10px 40px rgba(0,0,0,.4)}
h1{font-size:26px;margin:0 0 4px;color:#fff} .sub{color:var(--muted);font-size:13px;margin:0 0 24px}
.star{color:var(--accent)} label{display:block;font-size:12px;color:var(--muted);margin:14px 0 4px}
input{width:100%;padding:11px 12px;border-radius:8px;border:1px solid #26304f;background:#0d1325;color:var(--text);font-size:14px}
input:focus{outline:none;border-color:var(--accent)}
button{width:100%;padding:12px;margin-top:20px;border:0;border-radius:8px;background:var(--accent);color:#fff;font-size:15px;font-weight:600;cursor:pointer}
button.alt{background:transparent;border:1px solid #26304f;color:var(--text);margin-top:10px}
a{color:var(--accent);text-decoration:none;font-size:13px;display:block;text-align:center;margin-top:14px}
.err{color:#e74c3c;font-size:13px;margin-top:10px;min-height:16px}
.cap{display:flex;gap:10px;align-items:center;margin-top:14px}.cap .q{font-size:14px;color:var(--text);flex:1}.cap input{width:90px}
</style></head><body><div class="card">
<h1><span class="star">✦</span> Rigel</h1><p class="sub">No-KYC SOCKS5 &amp; residential proxies. Pay in Bitcoin.</p>
<div id="body"></div>
<script>
let mode="{{mode}}";
function esc(s){return s.replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
async function captcha(){let r=await fetch('/api/captcha').then(x=>x.json());document.getElementById('cq').innerText=r.question;}
function loginForm(){
return `<label>Username</label><input id="u" maxlength="25">
<label>Password</label><input id="p" type="password" maxlength="30">
<div class="cap"><span class="q" id="cq"></span><input id="c" maxlength="6" placeholder="answer"></div>
<button onclick="doLogin()">Login</button>
<a href="javascript:regForm()">Create an account</a><div class="err" id="err"></div>`;
}
function regForm(){
return `<label>Username</label><input id="u" maxlength="25">
<label>Email <span style="color:#5a6480">(optional, for restore)</span></label><input id="e" maxlength="75">
<label>Password</label><input id="p" type="password" maxlength="30">
<div class="cap"><span class="q" id="cq"></span><input id="c" maxlength="6" placeholder="answer"></div>
<button onclick="doRegister()">Create account</button>
<a href="javascript:loginForm()">Already have an account?</a><div class="err" id="err"></div>`;
}
function err(s){document.getElementById('err').innerText=s;}
async function doLogin(){
let r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:document.getElementById('u').value,password:document.getElementById('p').value,captcha:document.getElementById('c').value})});
if(r.ok){location.reload();}else{let j=await r.json();err(j.error||'login failed');captcha();}
}
async function doRegister(){
let r=await fetch('/api/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:document.getElementById('u').value,email:document.getElementById('e').value,password:document.getElementById('p').value,captcha:document.getElementById('c').value})});
if(r.ok){loginForm();err('account created — log in');captcha();}else{let j=await r.json();err(j.error||'registration failed');captcha();}
}
if(mode==='login'){document.getElementById('body').innerHTML=loginForm();captcha();}
</script></div></body></html>
"""
DASH_HTML = r"""
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Rigel — Dashboard</title>
<style>
:root{--bg:#0a0e1a;--card:#12182b;--accent:#4f8cff;--text:#e6ebf5;--muted:#8b95ad}
*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
header{display:flex;justify-content:space-between;align-items:center;padding:16px 28px;background:var(--card);border-bottom:1px solid #1e2742}
header h1{margin:0;font-size:20px}header h1 span{color:var(--accent)}
header button{background:transparent;border:1px solid #26304f;color:var(--text);padding:8px 14px;border-radius:8px;cursor:pointer}
main{padding:28px;max-width:900px;margin:0 auto}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px}
.loc{background:var(--card);border:1px solid #1e2742;border-radius:12px;padding:20px}
.loc h3{margin:0 0 4px;font-size:17px;color:#fff}.loc .type{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
.loc .plans{margin-top:14px}.loc .plan{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-top:1px solid #1e2742;font-size:13px}
.loc button{background:var(--accent);color:#fff;border:0;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px}
.subs{margin-top:32px}.subs h2{font-size:17px}.subs table{width:100%;border-collapse:collapse;font-size:13px}
.subs th,.subs td{padding:10px;text-align:left;border-bottom:1px solid #1e2742}.subs th{color:var(--muted);font-weight:500}
.badge{padding:3px 8px;border-radius:10px;font-size:11px}.active{background:#14321f;color:#2ecc71}.pending{background:#332a14;color:#f1c40f}.expired{background:#33171a;color:#e74c3c}
.subcard{background:var(--card);border:1px solid #1e2742;border-radius:12px;padding:18px;margin-top:14px}
.srow{font-size:15px;margin-bottom:6px}
.lbl{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-top:10px}
code{display:block;background:#0d1325;border:1px solid #26304f;border-radius:6px;padding:8px 10px;font-size:12px;color:#9fd0ff;word-break:break-all;margin-top:3px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
</style></head><body>
<header><h1><span>✦</span> Rigel <span style="color:var(--muted);font-weight:400;font-size:13px">— {{user["username"]}}</span></h1><button onclick="logout()">Logout</button></header>
<main>
<div class="grid">
{% for key, loc in locations.items() %}
<div class="loc">
<h3>{{loc["name"]}}</h3><div class="type">{{loc["type"]}}</div>
<div class="plans">
{% for pk, p in plans.items() %}
<div class="plan"><span>{{p["label"]}}</span><span><b>{{p["sats"]}}</b> sats</span><button onclick="buy('{{key}}','{{pk}}')">Buy</button></div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<div class="subs"><h2>Your subscriptions</h2><div id="subs">Loading…</div></div>
</main>
<script>
async function buy(loc, plan){
let r=await fetch('/api/buy',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:loc,plan:plan})});
let j=await r.json();
if(j.ok && j.checkout){window.location=j.checkout;}else{alert(j.error||'error');}
}
async function logout(){await fetch('/api/logout',{method:'POST'});location.reload();}
async function loadSubs(){
let r=await fetch('/api/subscriptions').then(x=>x.json());
if(!r.length){document.getElementById('subs').innerHTML='<p style="color:var(--muted)">No subscriptions yet.</p>';return;}
let h='';
for(const s of r){
h+=`<div class="subcard"><div class="srow"><b style="text-transform:capitalize">${s.location}</b> &middot; ${s.plan} &middot; <span class="badge ${s.status}">${s.status}</span></div>`;
if(s.status==='active'&&s.proxy_user){
h+=`<div class="lbl">SOCKS5 endpoint</div><code>{{proxy_host}}:{{proxy_port}}</code>`
+ `<div class="lbl">Username</div><code>${s.proxy_user}</code>`
+ `<div class="lbl">Password</div><code>${s.proxy_pass||'\u2014'}</code>`
+ `<div class="lbl">Expires (UTC)</div><code>${s.expires_at||'\u2014'}</code>`
+ `<div class="lbl">Test it</div><code>curl --socks5-hostname ${s.proxy_user}:${s.proxy_pass}@{{proxy_host}}:{{proxy_port}} https://api.ipify.org</code>`;
} else {
h+=`<div style="color:var(--muted);font-size:13px;margin-top:8px">Awaiting payment confirmation\u2026 credentials appear here automatically.</div>`;
}
h+='</div>';
}
document.getElementById('subs').innerHTML=h;
}
loadSubs();
</script></body></html>
"""
if __name__ == "__main__":
init_db()
app.run(host="0.0.0.0", port=5000, debug=False)