Add dedicated-IP tier: IPRoyal DC/ISP reseller API, location picker, auto-provisioning
This commit is contained in:
139
app.py
139
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",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
19
db.py
19
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
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
91
iproyal.py
Normal file
91
iproyal.py
Normal file
@@ -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()
|
||||
6
provision.py
Normal file
6
provision.py
Normal file
@@ -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}")
|
||||
8
provision.service
Normal file
8
provision.service
Normal file
@@ -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
|
||||
9
provision.timer
Normal file
9
provision.timer
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Poll dedicated-IP provisioning every 5 min
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=5min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -17,7 +17,8 @@
|
||||
<a class="logo" href="/">CLEAN<span class="dot">.</span>PROXYS</a>
|
||||
<div class="nav-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/plans">Plans</a>
|
||||
<a href="/plans">Residential</a>
|
||||
<a href="/dedicated">Dedicated IPs</a>
|
||||
{% if user %}<a href="/dashboard">Dashboard</a><a href="/logout">Logout</a>
|
||||
{% else %}<a href="/login">Login</a><a class="cta" href="/signup">Sign up</a>{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if dedicated %}
|
||||
<div class="card" style="margin-top:18px">
|
||||
<h3>Dedicated IPs</h3>
|
||||
{% for d in dedicated %}
|
||||
<div style="border:1px solid var(--border);border-radius:8px;padding:12px;margin:10px 0">
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<strong style="color:var(--text)">{{ d.product_name }} · {{ d.location_name }}</strong>
|
||||
<span style="color:{{ {'active':'var(--ok)','provisioning':'var(--warn)','pending_payment':'var(--warn)','failed':'var(--danger)','expired':'var(--muted)'}.get(d.status,'var(--muted)') }}">{{ d.status }}</span>
|
||||
</div>
|
||||
{% if d.status == 'active' %}
|
||||
<div class="copy" style="margin-top:8px">
|
||||
<input type="text" readonly value="{{ d.proxy_host }}:{{ d.proxy_port }}" onclick="this.select()">
|
||||
<button onclick="copy(this.previousElementSibling)">Copy</button>
|
||||
</div>
|
||||
<div class="copy" style="margin-top:6px">
|
||||
<input type="text" readonly value="{{ d.proxy_user }}:{{ d.proxy_pass }}" onclick="this.select()">
|
||||
<button onclick="copy(this.previousElementSibling)">Copy</button>
|
||||
</div>
|
||||
{% elif d.status in ('provisioning','pending_payment') %}
|
||||
<p class="muted" style="font-size:12px;margin:6px 0 0">Provisioning — refresh shortly for your IP.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<p class="muted" style="font-size:12px;margin:0"><a href="/dedicated">Order another dedicated IP →</a></p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" style="margin-top:18px">No dedicated IPs yet — <a href="/dedicated">order one</a>.</p>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function copy(el){ el.select(); document.execCommand('copy'); }
|
||||
</script>
|
||||
|
||||
77
templates/dedicated.html
Normal file
77
templates/dedicated.html
Normal file
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<div class="hero" style="padding-bottom:20px">
|
||||
<h1>Dedicated <span class="glow">IPs</span>.</h1>
|
||||
<p class="lede">One clean IP, yours alone. Static datacenter or premium residential-ISP. Provisioned automatically from a global pool.</p>
|
||||
</div>
|
||||
|
||||
<div class="dash-grid">
|
||||
<div class="card">
|
||||
<h3>Configure your IP</h3>
|
||||
<form method="post" action="/dedicated/buy" onsubmit="document.getElementById('loc_name').value=document.getElementById('loc').selectedOptions[0].textContent">
|
||||
<label>Type</label>
|
||||
<div class="grid" style="grid-template-columns:1fr 1fr">
|
||||
{% for pid, p in products.items() %}
|
||||
<label class="switch" style="cursor:pointer">
|
||||
<input type="radio" name="product" value="{{ pid }}" {% if loop.first %}checked{% endif %} style="width:auto">
|
||||
<span>{{ p.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<label>Duration</label>
|
||||
<div class="grid" style="grid-template-columns:1fr 1fr 1fr">
|
||||
{% for days in [30, 60, 90] %}
|
||||
<label class="switch" style="cursor:pointer">
|
||||
<input type="radio" name="days" value="{{ days }}" {% if days == 30 %}checked{% endif %} style="width:auto">
|
||||
<span>{{ days }}d</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<label>Location</label>
|
||||
<select id="loc" name="location_id"></select>
|
||||
<input type="hidden" name="location_name" id="loc_name" value="">
|
||||
|
||||
<input type="submit" value="Order dedicated IP">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="card">
|
||||
<h3>Pricing</h3>
|
||||
{% for pid, p in products.items() %}
|
||||
<p class="muted" style="margin:0 0 6px"><strong style="color:var(--text)">{{ p.name }}</strong> — {{ p.desc }}</p>
|
||||
{% for days, (_, sats) in p.plans.items() %}
|
||||
<div class="copy" style="margin-bottom:4px">
|
||||
<span class="mono" style="flex:1">{{ days }} days</span>
|
||||
<span class="mono" style="color:var(--neon)">{{ sats }} sats</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<hr style="border-color:var(--border);margin:12px 0">
|
||||
{% endfor %}
|
||||
<p class="muted" style="font-size:12px">IPs provision automatically after payment (usually minutes). You get the host:port + credentials in your dashboard once live.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const locs = {
|
||||
3: {{ dc_locs | tojson }},
|
||||
9: {{ isp_locs | tojson }}
|
||||
};
|
||||
function updateLocs() {
|
||||
const pid = document.querySelector('input[name=product]:checked').value;
|
||||
const sel = document.getElementById('loc');
|
||||
sel.innerHTML = '';
|
||||
(locs[pid] || []).forEach(([id, name, stock]) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = id; o.textContent = name + (stock ? '' : ' — out of stock');
|
||||
if (!stock) o.disabled = true;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('input[name=product]').forEach(r => r.onchange = updateLocs);
|
||||
updateLocs();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user