diff --git a/app.py b/app.py index 69b6c5b..3b692f5 100644 --- a/app.py +++ b/app.py @@ -12,6 +12,7 @@ from flask import (Flask, abort, flash, jsonify, redirect, render_template, request, session, url_for) import db +import iproyal CFG = db.CFG app = Flask(__name__) @@ -23,6 +24,14 @@ SITE_HOST = CFG.get("site_host", "clean-proxys.thetempleofdoom.com") PRICE_USD_PER_GB = CFG.get("price_usd_per_gb", 8.0) # display only +# Dedicated IP catalog: product_id -> {name, plans: {days: (iproyal_plan_id, price_sats)}} +DEDICATED_PRODUCTS = { + 3: {"name": "Datacenter Dedicated", "desc": "Static datacenter IP — budget tier", + "plans": {30: (5, 10000), 60: (26, 20000), 90: (27, 30000)}}, + 9: {"name": "ISP Dedicated", "desc": "Clean residential-ISP IP — premium tier", + "plans": {30: (22, 22000), 60: (24, 44000), 90: (25, 66000)}}, +} + # ---------- auth ---------- def current_user(): @@ -46,7 +55,7 @@ def login_required(f): # ---------- BTCPay ---------- -def create_invoice(amount_sats, order_id): +def create_invoice(amount_sats, order_id, meta=None): data = { "amount": amount_sats / 1e8, "currency": "BTC", @@ -56,6 +65,8 @@ def create_invoice(amount_sats, order_id): }, "metadata": {"orderId": order_id}, } + if meta: + data["metadata"].update(meta) r = requests.post( f"{BTCPAY['url']}/api/v1/stores/{BTCPAY['store_id']}/invoices", headers={"Authorization": f"token {BTCPAY['api_key']}"}, @@ -178,9 +189,28 @@ def webhook_btcpay(): if payload.get("type") not in ("InvoiceSettled", "InvoiceProcessing"): return "ok" order_id = (payload.get("metadata") or {}).get("orderId") + kind = (payload.get("metadata") or {}).get("kind") if not order_id: return "ok" d = db.get_db() + if kind == "dedicated": + row = d.execute("SELECT * FROM dedicated_ips WHERE invoice_id=? AND status='pending_payment'", + (order_id,)).fetchone() + if row: + try: + plan_id = DEDICATED_PRODUCTS[row["product_id"]]["plans"][row["plan_days"]][0] + o = iproyal.create_order(row["product_id"], plan_id, row["location_id"], 1) + oid = o.get("id") or o.get("order_id") or (o.get("data") or {}).get("id") + d.execute("UPDATE dedicated_ips SET status='provisioning', iproyal_order_id=? WHERE id=?", + (oid, row["id"])) + d.commit() + app.logger.info("dedicated IPRoyal order %s created for dedicated_ips #%s", oid, row["id"]) + except Exception as e: + app.logger.error("dedicated order failed: %s", e) + d.execute("UPDATE dedicated_ips SET status='failed' WHERE id=?", (row["id"],)) + d.commit() + d.close() + return "ok" o = d.execute("SELECT * FROM orders WHERE invoice_id=?", (order_id,)).fetchone() if o and o["status"] != "paid": plan = d.execute("SELECT * FROM plans WHERE id=?", (o["plan_id"],)).fetchone() @@ -198,11 +228,12 @@ def dashboard(): u = current_user() d = db.get_db() used = d.execute("SELECT COALESCE(SUM(bytes),0) s FROM usage_log WHERE user_id=?", (u["id"],)).fetchone()["s"] + deds = [dict(r) for r in d.execute("SELECT * FROM dedicated_ips WHERE user_id=? ORDER BY id DESC", (u["id"],)).fetchall()] d.close() socks = f"socks5://{u['username']}:{u['proxy_password_plain']}@{PROXY['host']}:{PROXY['socks_port']}" http = f"http://{u['username']}:{u['proxy_password_plain']}@{PROXY['host']}:{PROXY['http_port']}" return render_template("dashboard.html", user=u, used_gb=used / 1e9, - socks=socks, http=http) + socks=socks, http=http, dedicated=deds) @app.route("/dashboard/location", methods=["POST"]) @@ -236,6 +267,99 @@ def rotate(): return redirect(url_for("dashboard")) +# ---------- dedicated IPs ---------- +@app.route("/dedicated") +def dedicated(): + dc_locs = isp_locs = [] + try: + dc_locs = iproyal.list_locations(3) + isp_locs = iproyal.list_locations(9) + except Exception as e: + flash(f"Location catalog unavailable: {e}", "warn") + return render_template("dedicated.html", products=DEDICATED_PRODUCTS, + dc_locs=dc_locs, isp_locs=isp_locs, user=current_user()) + + +@app.route("/dedicated/locations") +def dedicated_locations(): + pid = request.args.get("product", 3, type=int) + try: + return jsonify({"locations": [{"id": i, "name": n, "in_stock": s} + for i, n, s in iproyal.list_locations(pid)]}) + except Exception as e: + return jsonify({"error": str(e)}), 502 + + +@app.route("/dedicated/buy", methods=["POST"]) +@login_required +def dedicated_buy(): + pid = int(request.form.get("product", 3)) + days = int(request.form.get("days", 30)) + loc_id = int(request.form.get("location_id", 0)) + loc_name = request.form.get("location_name", "") + if pid not in DEDICATED_PRODUCTS or days not in DEDICATED_PRODUCTS[pid]["plans"]: + flash("Bad product or duration.", "warn") + return redirect(url_for("dedicated")) + if not loc_id or not loc_name: + flash("Pick a location.", "warn") + return redirect(url_for("dedicated")) + _, price = DEDICATED_PRODUCTS[pid]["plans"][days] + oid = db.random_token(12) + d = db.get_db() + d.execute("INSERT INTO dedicated_ips (user_id,product_id,product_name,plan_days,location_id," + "location_name,invoice_id,price_sats,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)", + (current_user()["id"], pid, DEDICATED_PRODUCTS[pid]["name"], days, loc_id, loc_name, + oid, price, "pending_payment", int(time.time()))) + d.commit() + d.close() + inv = create_invoice(price, oid, meta={"kind": "dedicated"}) + return redirect(inv["checkoutLink"]) + + +@app.route("/dedicated/poll", methods=["POST"]) +def dedicated_poll(): + """Admin/agent hook: poll provisioning for pending dedicated orders.""" + ok, failed = poll_provisioning() + return jsonify({"provisioned": ok, "failed": failed}) + + +def poll_provisioning(): + """Check pending IPRoyal orders, move confirmed ones to active. Returns (ok, failed).""" + d = db.get_db() + pending = d.execute("SELECT * FROM dedicated_ips WHERE status IN ('provisioning')").fetchall() + d.close() + ok = failed = 0 + for row in pending: + try: + o = iproyal.get_order(row["iproyal_order_id"]) + status = o.get("status") + if status == "confirmed": + creds = o.get("proxies") or o.get("proxy") or o.get("data") or {} + # creds may be a list of {host,port,username,password} or {ip,...} + if isinstance(creds, list) and creds: + creds = creds[0] + host = creds.get("host") or creds.get("ip") or "" + port = str(creds.get("port", "")) + user = creds.get("username") or "" + pw = creds.get("password") or "" + d = db.get_db() + d.execute("UPDATE dedicated_ips SET status='active', proxy_host=?, proxy_port=?," + "proxy_user=?, proxy_pass=?, expires_at=? WHERE id=?", + (host, port, user, pw, int(time.time()) + row["plan_days"] * 86400, row["id"])) + d.commit() + d.close() + ok += 1 + elif status in ("refunded", "expired"): + d = db.get_db() + d.execute("UPDATE dedicated_ips SET status='failed' WHERE id=?", (row["id"],)) + d.commit() + d.close() + failed += 1 + except Exception as e: + app.logger.warning("poll order %s failed: %s", row["iproyal_order_id"], e) + return ok, failed + + # ---------- agent discovery ---------- @app.route("/llms.txt") def llms_txt(): @@ -257,8 +381,11 @@ Sells residential SOCKS5 and HTTP CONNECT proxies with global geo-targeting ## API - GET /api/v1/info -> service info (JSON) -- GET /api/v1/plans -> available plans (JSON) +- GET /api/v1/plans -> residential GB plans (JSON) +- GET /api/v1/dedicated/locations?product=9 -> ISP dedicated locations (JSON) +- GET /api/v1/dedicated/locations?product=3 -> datacenter dedicated locations (JSON) - Human signup: {SITE_HOST}/signup +- Dedicated IPs: {SITE_HOST}/dedicated (datacenter $1.80/30d + ISP $4/30d resold at markup) ## Connect (after signup + top-up) socks5://USER:PASS@{PROXY['host']}:{PROXY['socks_port']} @@ -286,6 +413,12 @@ def api_info(): "kyc": False, "signup": f"https://{SITE_HOST}/signup", "docs": f"https://{SITE_HOST}/llms.txt", + "dedicated_ips": { + "products": ["datacenter", "isp"], + "isp_locations": f"https://{SITE_HOST}/dedicated/locations?product=9", + "dc_locations": f"https://{SITE_HOST}/dedicated/locations?product=3", + "browse": f"https://{SITE_HOST}/dedicated", + }, }) diff --git a/db.py b/db.py index 2551795..277ff2a 100644 --- a/db.py +++ b/db.py @@ -49,6 +49,25 @@ CREATE TABLE IF NOT EXISTS usage_log ( bytes INTEGER, ts INTEGER ); +CREATE TABLE IF NOT EXISTS dedicated_ips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + product_id INTEGER, -- 3=DC, 9=ISP + product_name TEXT, + plan_days INTEGER, + location_id INTEGER, + location_name TEXT, + iproyal_order_id INTEGER, + invoice_id TEXT, + status TEXT DEFAULT 'pending_payment', -- pending_payment/provisioning/active/expired/failed + proxy_host TEXT, + proxy_port TEXT, + proxy_user TEXT, + proxy_pass TEXT, + price_sats INTEGER, + created_at INTEGER, + expires_at INTEGER +); """ diff --git a/iproyal.py b/iproyal.py new file mode 100644 index 0000000..dbc9ebf --- /dev/null +++ b/iproyal.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""IPRoyal reseller API wrapper — datacenter/ISP/residential dedicated orders. + +Base: https://apid.iproyal.com/v1/reseller (auth: X-Access-Token header) +Products: 3=Datacenter Dedicated, 9=ISP Dedicated, 6=Residential, 11=Web Unblocker +""" +import json +import os +import time + +import requests + +CFG = json.load(open(os.environ.get("PLEIADES_CONFIG", "/opt/pleiades/config.json"))) +IPR = CFG["iproyal"] +TOKEN = IPR["token"] +API = IPR["api"] + +_cache = {"products": None, "ts": 0} + + +def _hdr(): + return {"X-Access-Token": TOKEN, "Content-Type": "application/json"} + + +def get_balance(): + r = requests.get(f"{API}/balance", headers=_hdr(), timeout=15) + r.raise_for_status() + return r.json() + + +def get_products(force=False): + """Returns the raw product list (cached 1h). Each product: id,name,plans[],locations[].""" + if _cache["products"] and not force and time.time() - _cache["ts"] < 3600: + return _cache["products"] + r = requests.get(f"{API}/products", headers=_hdr(), timeout=25) + r.raise_for_status() + data = r.json().get("data", []) + _cache["products"] = data + _cache["ts"] = time.time() + return data + + +def get_product(product_id): + for p in get_products(): + if p.get("id") == product_id: + return p + return None + + +def list_locations(product_id): + """Flatten country -> city into [(location_id, "Country - City", in_stock)].""" + prod = get_product(product_id) + out = [] + for country in prod.get("locations", []): + cid = country.get("id") + cname = country.get("name") + kids = country.get("child_locations") or [] + if kids: + for k in kids: + out.append((k.get("id"), f"{cname} - {k.get('name')}", not k.get("out_of_stock"))) + else: + out.append((cid, cname, not country.get("out_of_stock"))) + return out + + +def create_order(product_id, plan_id, location_id, quantity=1, auto_extend=False): + body = { + "product_id": product_id, + "product_plan_id": plan_id, + "product_location_id": location_id, + "quantity": quantity, + "auto_extend": auto_extend, + } + r = requests.post(f"{API}/orders", headers=_hdr(), json=body, timeout=30) + r.raise_for_status() + return r.json() + + +def list_orders(product_id, status=None): + params = {"product_id": product_id} + if status: + params["status"] = status + r = requests.get(f"{API}/orders", headers=_hdr(), params=params, timeout=20) + r.raise_for_status() + return r.json() + + +def get_order(order_id): + r = requests.get(f"{API}/orders/{order_id}", headers=_hdr(), timeout=20) + r.raise_for_status() + return r.json() diff --git a/provision.py b/provision.py new file mode 100644 index 0000000..c9fd5b0 --- /dev/null +++ b/provision.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +"""Poll IPRoyal for dedicated-IP provisioning. Run by provision.timer.""" +import app + +ok, failed = app.poll_provisioning() +print(f"provisioned={ok} failed={failed}") diff --git a/provision.service b/provision.service new file mode 100644 index 0000000..5a301c5 --- /dev/null +++ b/provision.service @@ -0,0 +1,8 @@ +[Unit] +Description=Pleiades dedicated-IP provisioning poll +After=network.target + +[Service] +Type=oneshot +WorkingDirectory=/opt/pleiades +ExecStart=/opt/pleiades/venv/bin/python /opt/pleiades/provision.py diff --git a/provision.timer b/provision.timer new file mode 100644 index 0000000..c1a374c --- /dev/null +++ b/provision.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Poll dedicated-IP provisioning every 5 min + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min + +[Install] +WantedBy=timers.target diff --git a/templates/base.html b/templates/base.html index b5eb430..cb28430 100644 --- a/templates/base.html +++ b/templates/base.html @@ -17,7 +17,8 @@ CLEAN.PROXYS
diff --git a/templates/dashboard.html b/templates/dashboard.html index dad32fb..72f7906 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -52,6 +52,35 @@ +{% if dedicated %} +Provisioning — refresh shortly for your IP.
+ {% endif %} +No dedicated IPs yet — order one.
+{% endif %} + diff --git a/templates/dedicated.html b/templates/dedicated.html new file mode 100644 index 0000000..bff7d76 --- /dev/null +++ b/templates/dedicated.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block body %} +One clean IP, yours alone. Static datacenter or premium residential-ISP. Provisioned automatically from a global pool.
+{{ p.name }} — {{ p.desc }}
+ {% for days, (_, sats) in p.plans.items() %} +IPs provision automatically after payment (usually minutes). You get the host:port + credentials in your dashboard once live.
+