Unified storefront + tiered pricing (1 free, 2&3 billed) + token auth + ProxyFly clean-proxy auto-assign on sessions

This commit is contained in:
drjones
2026-08-24 22:30:56 -07:00
parent 1b586daf20
commit 6e3b1f11be
3 changed files with 497 additions and 316 deletions

297
server.ts
View File

@@ -3,6 +3,7 @@ import path from "path";
import fs from "fs";
import crypto from "crypto";
import https from "https";
import http from "http";
import { execFile } from "child_process";
import { promisify } from "util";
import { createServer as createViteServer } from "vite";
@@ -14,14 +15,15 @@ const exec = promisify(execFile);
/**
* VortexGPU — rent-a-PC platform (production build)
*
* Real virtualization: drives Proxmox via `qm` over SSH to clone real KVM VMs.
* - Windows 10 (RDP) : clone template 504 (comandoVM, template:1)
* - Linux (SSH) : clone template 990 (vortex-linux-tpl, debian cloud-init)
* GPU offload: hashcat/comfyui jobs dispatch to the Windows GPU hosts (.128 4080S,
* .186 3070) over LAN — the shared GPU pool is hidden from tenants.
* Per-tenant access: each VM gets a dedicated RDP/SSH port + subdomain (real
* per-tenant entry; true rotating-residential IPs would need a proxy provider).
* Billing: $1/hr flat, up to 3 VMs per user, auto-stop at zero balance.
* Products (unified storefront):
* - Ubuntu GPU Session (in-browser desktop, 4080 SUPER attached, noVNC)
* - Windows 10 (RDP) : clone template 504 (comandoVM)
* - Linux (SSH) : clone template 990 (vortex-linux-tpl)
* Pricing: $1/hr flat. FIRST machine free per account; the 2nd and 3rd bill.
* Sessions are metered exactly like VMs (no more free sessions).
* Auth: token-based register/login/logout (everyone gets their own account).
* Proxies: ProxyFly clean residential pool refreshed in the background and
* auto-assigned to each Ubuntu session on spawn.
* Payments: BTCPay (real invoices + HMAC-signed webhook settlement).
* Admin: /admin?token= (404 without token) + /api/admin/* (Bearer).
*/
@@ -45,6 +47,7 @@ const BTCPAY_PUBLIC = process.env.BTCPAY_PUBLIC || "https://btcpay.thetempleofdo
const WEBHOOK_SECRET = process.env.BTCPAY_WEBHOOK_SECRET || "vortexgpu-webhook-secret-2026";
const PRICE_USD_PER_HOUR = 1.0;
const MAX_VMS_PER_USER = 3;
const FREE_MACHINES = Number(process.env.FREE_MACHINES) || 1; // 1st machine free, 2nd+ billed
// Marketing tier label (what tenants see) — configurable, decoupled from truth.
const GPU_SKU = process.env.GPU_SKU || "NVIDIA GeForce RTX 4080 SUPER 16GB";
@@ -124,18 +127,20 @@ db.exec(`
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
instance_id TEXT NOT NULL UNIQUE, node_hostname TEXT NOT NULL,
node_ip TEXT NOT NULL, port INTEGER NOT NULL, password TEXT NOT NULL,
resolution TEXT, state TEXT NOT NULL DEFAULT 'provisioning',
resolution TEXT, proxy TEXT, -- clean ProxyFly proxy (auto-assigned)
state TEXT NOT NULL DEFAULT 'provisioning',
created_at INTEGER NOT NULL
);
`);
// ---- Migrations (add columns that predate password/unlimited) ----
// ---- Migrations (add columns that predate password/unlimited/proxy) ----
function ensureColumn(table: string, col: string, ddl: string) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map((c: any) => c.name);
if (!cols.includes(col)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
}
ensureColumn("users", "password_hash", "password_hash TEXT");
ensureColumn("users", "unlimited", "unlimited INTEGER NOT NULL DEFAULT 0");
ensureColumn("sessions", "proxy", "proxy TEXT");
function q(sql: string, ...p: (string | number)[]) { return db.prepare(sql).run(...p); }
function one<T>(sql: string, ...p: (string | number)[]): T | undefined { return db.prepare(sql).get(...p) as T | undefined; }
@@ -178,14 +183,13 @@ function pve(args: string[]): Promise<{ ok: boolean; out: string }> {
}
function nextVmid(): number {
// pick the highest existing VMID below our floor, or the floor itself
return PVE_VMID_START;
const row = one<{ max_id: number | null }>("SELECT MAX(vm_id) as max_id FROM vms WHERE vm_id >= ?", PVE_VMID_START);
return (row?.max_id ?? PVE_VMID_START - 1) + 1;
}
async function cloneVm(template: number, vmid: number, name: string): Promise<{ ok: boolean; out: string }> {
// Long-running (250GB full clone). Use `qm clone` with a generous timeout —
// the 3GB Linux clone is quick, Windows 250GB needs ~5-10 min. Return the
// task as detached via `setsid` so the gateway SSH session isn't the lifeline.
// the 3GB Linux clone is quick, Windows 250GB needs ~5-10 min.
const r = await pve(["qm", "clone", String(template), String(vmid), "--name", name, "--full"]);
return r;
}
@@ -229,6 +233,93 @@ function dispatchJob(hostname: string, kind: GpuJob["kind"], command: string, pa
function nodeAuthorized(req: express.Request) { return req.headers["x-node-secret"] === NODE_SECRET; }
function adminAuthorized(req: express.Request) { return (req.headers["authorization"] || "") === `Bearer ${ADMIN_TOKEN}`; }
// ---- User auth tokens (in-memory; issued on login/register) ----
const AUTH_TOKENS = new Map<string, string>(); // token -> userId
function issueToken(userId: string): string {
const token = crypto.randomBytes(24).toString("hex");
AUTH_TOKENS.set(token, userId);
return token;
}
function tokenFromReq(req: express.Request): string {
const auth = String(req.headers["authorization"] || "");
return auth.startsWith("Bearer ") ? auth.slice(7) : String(req.headers["x-auth-token"] || "");
}
function userFromReq(req: express.Request): any | null {
const token = tokenFromReq(req);
const userId = token ? AUTH_TOKENS.get(token) : undefined;
if (!userId) return null;
return one<any>("SELECT * FROM users WHERE id=?", userId) || null;
}
// ---- ProxyFly clean-proxy pool (background refresh + auto-assign) ----
type PoolProxy = { proxy: string; ip: string; port: number; protocol: string; location: string; anonymity: string; latencyMs: number; clean: boolean; };
let proxyPool: PoolProxy[] = [];
let proxyRefreshing = false;
const PROXY_SOURCE = "https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/countries/US/data.json";
function fetchProxies(): Promise<PoolProxy[]> {
return new Promise((resolve) => {
const req = https.get(PROXY_SOURCE, { headers: { "User-Agent": "VortexGPU/1.0" } }, (res) => {
let buf = ""; res.on("data", (c) => (buf += c));
res.on("end", () => {
try {
const arr: any[] = JSON.parse(buf);
resolve(arr.filter((p) => p?.ip && p?.port).map((p) => ({
proxy: p.proxy || `${p.protocol}://${p.ip}:${p.port}`, ip: String(p.ip), port: Number(p.port),
protocol: String(p.protocol || "http"), location: p.geolocation?.country || "?", anonymity: String(p.anonymity || "transparent"),
latencyMs: 0, clean: false,
})));
} catch { resolve([]); }
});
});
req.setTimeout(20000, () => { req.destroy(); resolve([]); });
req.on("error", () => resolve([]));
});
}
function testProxy(p: PoolProxy): Promise<PoolProxy | null> {
return new Promise((resolve) => {
if (p.protocol !== "http" && p.protocol !== "https") return resolve(null);
const t0 = Date.now();
const mod = p.protocol === "https" ? https : http;
const req = mod.request({ host: p.ip, port: p.port, method: "GET", path: "http://api.ipify.org", headers: { Host: "api.ipify.org" }, timeout: 8000 }, (res) => {
let b = ""; res.on("data", (c) => (b += c));
res.on("end", () => {
const egressIp = b.trim();
const clean = res.statusCode === 200 && /^\d{1,3}(\.\d{1,3}){3}$/.test(egressIp) && p.anonymity !== "transparent";
resolve(clean ? { ...p, latencyMs: Date.now() - t0, clean: true } : null);
});
});
req.on("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
req.end();
});
}
async function refreshProxyPool() {
if (proxyRefreshing) return;
proxyRefreshing = true;
try {
const list = await fetchProxies();
const candidates = list.filter((p) => p.anonymity === "elite" || p.anonymity === "anonymous").slice(0, 100);
const results = await Promise.all(candidates.map(testProxy));
proxyPool = results.filter((p): p is PoolProxy => !!p);
console.log(`[proxy] pool refreshed: ${proxyPool.length} clean / ${list.length} fetched`);
} catch (e) { console.error("[proxy]", e); }
finally { proxyRefreshing = false; }
}
function assignProxy(): PoolProxy | null {
if (!proxyPool.length) return null;
return proxyPool[Math.floor(Math.random() * proxyPool.length)];
}
// Count a user's active machines (VMs + sessions) for the free-slot / cap.
function countActive(userId: string): number {
const v = one<{ c: number }>("SELECT COUNT(*) as c FROM vms WHERE user_id=? AND state IN ('running','provisioning')", userId);
const s = one<{ c: number }>("SELECT COUNT(*) as c FROM sessions WHERE user_id=? AND state IN ('running','provisioning')", userId);
return (v?.c || 0) + (s?.c || 0);
}
function clientIp(req: express.Request): string {
const fwd = req.headers["x-forwarded-for"];
const raw = (Array.isArray(fwd) ? fwd[0] : fwd)?.toString().split(",")[0].trim() || req.socket.remoteAddress || "";
@@ -267,69 +358,84 @@ async function startServer() {
status: "ok", node: "VortexGPU",
gpuNodesOnline: online.length, gpuNodesTotal: Object.keys(nodes).length,
gpuSku: GPU_SKU, priceUsdPerHour: PRICE_USD_PER_HOUR, maxVmsPerUser: MAX_VMS_PER_USER,
freeMachines: FREE_MACHINES,
timestamp: new Date().toISOString(),
});
});
// Login / register. Requires username + password. Existing accounts must
// match their stored password; new accounts are created on first login.
app.post("/api/session", (req, res) => {
const username = str(req.body?.username, "").slice(0, 32);
app.get("/api/proxy/pool", (_req, res) => {
res.json({ count: proxyPool.length, proxies: proxyPool.slice(0, 20).map((p) => ({ ip: p.ip, location: p.location, latencyMs: p.latencyMs, anonymity: p.anonymity })) });
});
// ===== AUTH (register / login / logout) =====
function publicUser(u: any) {
return { id: u.id, username: u.username, balance_minutes: u.balance_minutes, unlimited: !!u.unlimited, free_machines: FREE_MACHINES, max_machines: MAX_VMS_PER_USER };
}
app.post("/api/auth/register", (req, res) => {
const username = str(req.body?.username, "").slice(0, 32).trim();
const password = str(req.body?.password, "");
if (!username) return res.status(400).json({ error: "username required" });
if (!password) return res.status(400).json({ error: "password required" });
if (!/^[a-zA-Z0-9_.-]{3,32}$/.test(username)) return res.status(400).json({ error: "username must be 3-32 chars (letters, numbers, _ . -)" });
if (password.length < 6) return res.status(400).json({ error: "password must be at least 6 chars" });
if (one<any>("SELECT id FROM users WHERE username=?", username)) return res.status(409).json({ error: "username already taken" });
let user = one<any>("SELECT * FROM users WHERE username=?", username);
if (user) {
// Existing account — require correct password.
if (!user.password_hash) {
// Legacy account (pre-password): set the password on first login.
q("UPDATE users SET password_hash=? WHERE id=?", hashPassword(password), user.id);
} else if (!verifyPassword(password, user.password_hash)) {
return res.status(401).json({ error: "wrong password" });
}
} else {
// New account.
user = {
id: "usr_" + crypto.randomBytes(8).toString("hex"),
username, balance_minutes: 60,
btc_address: "bc1q" + crypto.randomBytes(16).toString("hex"),
created_at: Date.now(), password_hash: hashPassword(password), unlimited: 0,
};
q("INSERT INTO users (id,username,balance_minutes,btc_address,created_at,password_hash,unlimited) VALUES (?,?,?,?,?,?,?)",
user.id, user.username, user.balance_minutes, user.btc_address, user.created_at, user.password_hash, user.unlimited);
const id = "usr_" + crypto.randomBytes(8).toString("hex");
q("INSERT INTO users (id,username,balance_minutes,btc_address,created_at,password_hash,unlimited) VALUES (?,?,?,?,?,?,?)",
id, username, 0, "bc1q" + crypto.randomBytes(16).toString("hex"), Date.now(), hashPassword(password), 0);
const user = one<any>("SELECT * FROM users WHERE id=?", id);
res.json({ token: issueToken(id), user: publicUser(user) });
});
app.post("/api/auth/login", (req, res) => {
const username = str(req.body?.username, "").trim();
const password = str(req.body?.password, "");
const user = one<any>("SELECT * FROM users WHERE username=?", username);
if (!user) return res.status(401).json({ error: "no account with that username" });
if (!user.password_hash) {
const h = hashPassword(password);
q("UPDATE users SET password_hash=? WHERE id=?", h, user.id);
user.password_hash = h;
}
res.json({
id: user.id, username: user.username,
balance_minutes: user.balance_minutes,
unlimited: !!user.unlimited, is_admin: false,
});
if (!verifyPassword(password, user.password_hash)) return res.status(401).json({ error: "wrong password" });
res.json({ token: issueToken(user.id), user: publicUser(user) });
});
app.post("/api/auth/logout", (req, res) => {
const token = tokenFromReq(req);
if (token) AUTH_TOKENS.delete(token);
res.json({ ok: true });
});
app.get("/api/me", (req, res) => {
const userId = str(req.query.userId, "");
if (!userId) return res.status(400).json({ error: "userId required" });
const user = one<any>("SELECT * FROM users WHERE id=?", userId);
if (!user) return res.status(404).json({ error: "not found" });
const vms = all<any>("SELECT * FROM vms WHERE user_id=? ORDER BY created_at DESC", userId);
res.json({ user: { ...user, password_hash: undefined }, vms, max_vms: user.unlimited ? -1 : MAX_VMS_PER_USER, gpu_sku: GPU_SKU });
const user = userFromReq(req);
if (!user) return res.status(401).json({ error: "not authenticated" });
const vms = all<any>("SELECT * FROM vms WHERE user_id=? ORDER BY created_at DESC", user.id);
const sessions = all<any>("SELECT * FROM sessions WHERE user_id=? ORDER BY created_at DESC", user.id);
res.json({
user: { id: user.id, username: user.username, balance_minutes: user.balance_minutes, unlimited: !!user.unlimited },
vms, sessions,
max_machines: user.unlimited ? -1 : MAX_VMS_PER_USER,
free_machines: user.unlimited ? MAX_VMS_PER_USER : FREE_MACHINES,
gpu_sku: GPU_SKU, price_per_hour: PRICE_USD_PER_HOUR,
});
});
// ===== VM PROVISIONING (real KVM clone) =====
app.post("/api/vms/provision", async (req, res) => {
const { userId, os, app } = req.body || {};
const user = one<any>("SELECT * FROM users WHERE id=?", str(userId, ""));
if (!user) return res.status(400).json({ error: "invalid user" });
const { os, app } = req.body || {};
const user = userFromReq(req);
if (!user) return res.status(401).json({ error: "not authenticated" });
const unlimited = !!user.unlimited;
if (!unlimited && user.balance_minutes <= 0) return res.status(402).json({ error: "insufficient balance — top up with Bitcoin" });
const myVms = all<any>("SELECT id FROM vms WHERE user_id=? AND state NOT IN ('destroyed','stopped')", user.id);
if (!unlimited && myVms.length >= MAX_VMS_PER_USER) return res.status(429).json({ error: `limit reached — max ${MAX_VMS_PER_USER} machines per account` });
const active = countActive(user.id);
if (!unlimited && active >= FREE_MACHINES && user.balance_minutes <= 0) return res.status(402).json({ error: "insufficient balance — your first machine is free; top up with Bitcoin for more" });
if (!unlimited && active >= MAX_VMS_PER_USER) return res.status(429).json({ error: `limit reached — max ${MAX_VMS_PER_USER} machines per account` });
const isWin = (os || "windows") === "windows";
const template = isWin ? PVE_TEMPLATE_WIN : PVE_TEMPLATE_LINUX;
const vmid = nextVmid() + Math.floor(Math.random() * 1000);
const vmUid = "vm_" + crypto.randomBytes(6).toString("hex");
const port = isWin ? allocatePort() : allocatePort(); // dedicated access port
const port = allocatePort(); // dedicated access port
const name = isWin ? `vortex-win-${vmid}` : `vortex-lin-${vmid}`;
const username = isWin ? "administrator" : "rent";
const password = "Vx" + crypto.randomBytes(6).toString("hex") + "!";
@@ -337,7 +443,7 @@ async function startServer() {
q("INSERT INTO vms (id,user_id,vm_id,node_hostname,name,os,sku,state,port,username,password,app,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
vmUid, user.id, vmid, PVE_HOST, name, isWin ? "windows" : "linux", GPU_SKU, "provisioning", port, username, password, str(app, ""), Date.now());
// clone + start (long-running; runs in background, polls are handled in billing tick)
// clone + start (long-running; runs in background)
cloneVm(template, vmid, name).then(async (r) => {
if (!r.ok) { q("UPDATE vms SET state='failed' WHERE id=?", vmUid); return; }
const s = await startVm(vmid);
@@ -353,8 +459,10 @@ async function startServer() {
});
app.post("/api/vms/destroy", async (req, res) => {
const { userId, vmId } = req.body || {};
const vm = one<any>("SELECT * FROM vms WHERE id=? AND user_id=?", str(vmId, ""), str(userId, ""));
const { vmId } = req.body || {};
const user = userFromReq(req);
if (!user) return res.status(401).json({ error: "not authenticated" });
const vm = one<any>("SELECT * FROM vms WHERE id=? AND user_id=?", str(vmId, ""), user.id);
if (!vm) return res.status(404).json({ error: "not found" });
q("UPDATE vms SET state='stopping' WHERE id=?", vm.id);
await stopVm(vm.vm_id);
@@ -364,11 +472,11 @@ async function startServer() {
// ===== BTCPAY =====
app.post("/api/btcpay/create-invoice", async (req, res) => {
const { userId, usdAmount } = req.body || {};
const { usdAmount } = req.body || {};
const user = userFromReq(req);
if (!user) return res.status(401).json({ error: "not authenticated" });
const amountUsd = Math.max(1, Number(usdAmount) || 5);
const minutes = Math.round(amountUsd / PRICE_USD_PER_HOUR * 60);
const user = one<any>("SELECT * FROM users WHERE id=?", str(userId, ""));
if (!user) return res.status(400).json({ error: "invalid user" });
if (!BTCPAY_API_KEY || !BTCPAY_STORE_ID) return res.status(500).json({ error: "BTCPay not configured" });
const { status, data } = await btcpay("POST", `/api/v1/stores/${BTCPAY_STORE_ID}/invoices`, {
@@ -385,11 +493,10 @@ async function startServer() {
app.post("/api/btcpay/webhook", (req, res) => {
const sig = req.headers["btcpay-sig"] as string;
if (sig) {
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from(JSON.stringify(req.body));
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(raw).digest("hex");
if (sig !== `sha256=${expected}`) return res.status(401).json({ error: "bad signature" });
}
if (!sig) return res.status(401).json({ error: "missing signature" });
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from(JSON.stringify(req.body));
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(raw).digest("hex");
if (sig !== `sha256=${expected}`) return res.status(401).json({ error: "bad signature" });
let payload: any = {};
try { payload = JSON.parse(Buffer.isBuffer(req.body) ? req.body.toString("utf8") : JSON.stringify(req.body)); } catch { return res.status(400).json({ error: "invalid json" }); }
@@ -405,9 +512,13 @@ async function startServer() {
// ===== UBUNTU GPU SESSIONS (spawn in-browser desktop with the 4080 attached) =====
app.post("/api/session/spawn", (req, res) => {
const { userId, resolution } = req.body || {};
const user = one<any>("SELECT * FROM users WHERE id=?", str(userId, ""));
if (!user) return res.status(400).json({ error: "invalid user" });
const { resolution } = req.body || {};
const user = userFromReq(req);
if (!user) return res.status(401).json({ error: "not authenticated" });
const unlimited = !!user.unlimited;
const active = countActive(user.id);
if (!unlimited && active >= FREE_MACHINES && user.balance_minutes <= 0) return res.status(402).json({ error: "insufficient balance — your first machine is free; top up with Bitcoin for more" });
if (!unlimited && active >= MAX_VMS_PER_USER) return res.status(429).json({ error: `limit reached — max ${MAX_VMS_PER_USER} machines per account` });
// Target the Linux GPU node (nightmare) that runs the Ubuntu-session agent.
const hostname = "nightmare";
@@ -421,22 +532,25 @@ async function startServer() {
const password = "Ub" + crypto.randomBytes(6).toString("hex") + "!";
const reso = str(resolution, "1440x900");
const id = "ses_" + crypto.randomBytes(8).toString("hex");
const proxy = assignProxy(); // clean ProxyFly proxy, auto-assigned in background
q("INSERT INTO sessions (id,user_id,instance_id,node_hostname,node_ip,port,password,resolution,state,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
id, user.id, instanceId, hostname, node.ip, port, password, reso, "provisioning", Date.now());
dispatchJob(hostname, "provision_ubuntu", "", { instanceId, port, password, resolution: reso });
q("INSERT INTO sessions (id,user_id,instance_id,node_hostname,node_ip,port,password,resolution,proxy,state,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
id, user.id, instanceId, hostname, node.ip, port, password, reso, proxy?.proxy ?? null, "provisioning", Date.now());
dispatchJob(hostname, "provision_ubuntu", "", { instanceId, port, password, resolution: reso, proxy: proxy?.proxy ?? null });
res.json({ id, instanceId, port, password, resolution: reso, state: "provisioning", url: `/session/${instanceId}/` });
res.json({ id, instanceId, port, password, resolution: reso, proxy: proxy?.proxy ?? null, state: "provisioning", url: `/session/${instanceId}/` });
});
app.get("/api/sessions", (req, res) => {
const userId = str(req.query.userId, "");
if (!userId) return res.status(400).json({ error: "userId required" });
res.json(all<any>("SELECT * FROM sessions WHERE user_id=? ORDER BY created_at DESC", userId));
const user = userFromReq(req);
if (!user) return res.status(401).json({ error: "not authenticated" });
res.json(all<any>("SELECT * FROM sessions WHERE user_id=? ORDER BY created_at DESC", user.id));
});
app.post("/api/session/destroy", (req, res) => {
const sess = one<any>("SELECT * FROM sessions WHERE id=? AND user_id=?", str(req.body?.sessionId, ""), str(req.body?.userId, ""));
const user = userFromReq(req);
if (!user) return res.status(401).json({ error: "not authenticated" });
const sess = one<any>("SELECT * FROM sessions WHERE id=? AND user_id=?", str(req.body?.sessionId, ""), user.id);
if (!sess) return res.status(404).json({ error: "not found" });
q("UPDATE sessions SET state='stopping' WHERE id=?", sess.id);
dispatchJob(sess.node_hostname, "destroy_ubuntu", "", { instanceId: sess.instance_id });
@@ -505,6 +619,7 @@ async function startServer() {
vms: all<any>("SELECT * FROM vms ORDER BY created_at DESC"),
users: all<any>("SELECT id,username,balance_minutes,unlimited,created_at FROM users ORDER BY created_at DESC"),
invoices: all<any>("SELECT * FROM invoices ORDER BY created_at DESC LIMIT 50"),
proxyPool: proxyPool.slice(0, 20).map((p) => ({ ip: p.ip, location: p.location, latencyMs: p.latencyMs })),
adminToken: ADMIN_TOKEN,
});
});
@@ -524,22 +639,30 @@ async function startServer() {
res.json({ ok: true });
});
// ===== BILLING ($1/hr, tick every minute, auto-stop at 0) =====
// ===== BILLING ($1/hr, tick every minute, first machine free, auto-stop at 0) =====
setInterval(() => {
try {
const running = all<any>("SELECT * FROM vms WHERE state='running'");
const runningVms = all<any>("SELECT * FROM vms WHERE state='running'");
const runningSessions = all<any>("SELECT * FROM sessions WHERE state='running'");
const perUser = new Map<string, number>();
for (const r of running) perUser.set(r.user_id, (perUser.get(r.user_id) || 0) + 1);
for (const [userId, count] of perUser) {
for (const r of runningVms) perUser.set(r.user_id, (perUser.get(r.user_id) || 0) + 1);
for (const s of runningSessions) perUser.set(s.user_id, (perUser.get(s.user_id) || 0) + 1);
for (const [userId, total] of perUser) {
const acct = one<any>("SELECT unlimited, balance_minutes FROM users WHERE id=?", userId);
if (!acct || acct.unlimited) continue; // unlimited accounts never bill or auto-stop
q("UPDATE users SET balance_minutes = MAX(0, balance_minutes - ?) WHERE id=?", count, userId);
const billable = Math.max(0, total - FREE_MACHINES); // first machine free
if (billable <= 0) continue;
q("UPDATE users SET balance_minutes = MAX(0, balance_minutes - ?) WHERE id=?", billable, userId);
const u = one<any>("SELECT balance_minutes FROM users WHERE id=?", userId);
if (u && u.balance_minutes <= 0) {
for (const r of running.filter((x) => x.user_id === userId)) {
for (const r of runningVms.filter((x) => x.user_id === userId)) {
q("UPDATE vms SET state='stopping' WHERE id=?", r.id);
stopVm(r.vm_id).then(() => q("UPDATE vms SET state='stopped' WHERE id=?", r.id));
}
for (const s of runningSessions.filter((x) => x.user_id === userId)) {
q("UPDATE sessions SET state='stopping' WHERE id=?", s.id);
dispatchJob(s.node_hostname, "destroy_ubuntu", "", { instanceId: s.instance_id });
}
}
}
} catch (e) { console.error("[billing]", e); }
@@ -574,10 +697,14 @@ async function startServer() {
});
}
// Kick the ProxyFly pool refresher (background auto-assign of clean proxies).
refreshProxyPool();
setInterval(refreshProxyPool, 5 * 60_000);
const server = app.listen(PORT, "0.0.0.0", () => {
console.log(`[VortexGPU] rent-a-PC gateway on :${PORT}`);
console.log(`[VortexGPU] Proxmox ${PVE_HOST} | win tpl ${PVE_TEMPLATE_WIN} | linux tpl ${PVE_TEMPLATE_LINUX}`);
console.log(`[VortexGPU] GPU SKU: ${GPU_SKU}`);
console.log(`[VortexGPU] GPU SKU: ${GPU_SKU} | $${PRICE_USD_PER_HOUR}/hr | ${FREE_MACHINES} free machine(s)`);
});
server.on("upgrade", sessionProxy.upgrade);
}