diff --git a/server.ts b/server.ts index a41c897..f476734 100644 --- a/server.ts +++ b/server.ts @@ -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(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(); // 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("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 { + 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 { + 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("SELECT id FROM users WHERE username=?", username)) return res.status(409).json({ error: "username already taken" }); - let user = one("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("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("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("SELECT * FROM users WHERE id=?", userId); - if (!user) return res.status(404).json({ error: "not found" }); - const vms = all("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("SELECT * FROM vms WHERE user_id=? ORDER BY created_at DESC", user.id); + const sessions = all("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("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("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("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("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("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("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("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("SELECT * FROM sessions WHERE user_id=? ORDER BY created_at DESC", user.id)); }); app.post("/api/session/destroy", (req, res) => { - const sess = one("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("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("SELECT * FROM vms ORDER BY created_at DESC"), users: all("SELECT id,username,balance_minutes,unlimited,created_at FROM users ORDER BY created_at DESC"), invoices: all("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("SELECT * FROM vms WHERE state='running'"); + const runningVms = all("SELECT * FROM vms WHERE state='running'"); + const runningSessions = all("SELECT * FROM sessions WHERE state='running'"); const perUser = new Map(); - 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("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("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); } diff --git a/src/App.tsx b/src/App.tsx index b2ecba2..4b2df65 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,161 +1,109 @@ import React, { useState, useEffect, useCallback } from 'react'; import { - Monitor, Cpu, Plus, Power, Play, Clock, Shield, Terminal, Zap, - Bitcoin, Laptop, Server, KeyRound, Hash, Sparkles, LogOut, X, ExternalLink, Copy, + Monitor, Cpu, Plus, Power, Clock, Shield, Terminal, Zap, + Bitcoin, Laptop, Server, KeyRound, Sparkles, LogOut, X, ExternalLink, Copy, Globe, UserPlus, LogIn, } from 'lucide-react'; import './index.css'; /** - * VortexGPU — rent-a-PC frontend. - * Fund with Bitcoin ($1/hr), deploy a Windows (RDP) or Linux (SSH) machine, - * one-click Hashcat/ComfyUI, up to 3 machines per account. + * VortexGPU — rent-a-PC storefront. + * Unified products: Ubuntu GPU Session (in-browser 4080), Windows RDP, Linux SSH. + * Pricing: $1/hr. First machine FREE, 2nd & 3rd billed. Bitcoin via BTCPay. + * Token auth: register / login / logout — everyone gets their own account. */ interface ApiVm { - id: string; - vm_id: number; - os: string; - sku: string; - state: string; - port: number | null; - username: string | null; - password: string | null; - app: string | null; - created_at: number; + id: string; vm_id: number; os: string; sku: string; state: string; + port: number | null; username: string | null; password: string | null; + app: string | null; created_at: number; } interface ApiSession { - id: string; - instance_id: string; - node_hostname: string; - port: number; - password: string; - resolution: string; - state: string; - created_at: number; + id: string; instance_id: string; node_hostname: string; port: number; + password: string; resolution: string; proxy: string | null; state: string; created_at: number; } -interface Session { id: string; username: string; balance_minutes: number; unlimited?: boolean; } +interface User { id: string; username: string; balance_minutes: number; unlimited?: boolean; } -export default function App() { - const [session, setSession] = useState(null); +function App() { + const [auth, setAuth] = useState<{ token: string; user: User } | null>(null); const [vms, setVms] = useState([]); const [sessions, setSessions] = useState([]); const [gpuSku, setGpuSku] = useState('NVIDIA GeForce RTX 4080 SUPER 16GB'); - const [maxVms, setMaxVms] = useState(3); - const [loginName, setLoginName] = useState(''); - const [loginPass, setLoginPass] = useState(''); - const [isPayOpen, setIsPayOpen] = useState(false); - const [deployOs, setDeployOs] = useState<'windows' | 'linux'>('windows'); - const [deployApp, setDeployApp] = useState(''); - const [loading, setLoading] = useState(false); + const [price, setPrice] = useState(1); + const [maxMachines, setMaxMachines] = useState(3); + const [freeMachines, setFreeMachines] = useState(1); const [error, setError] = useState(''); - - const refresh = useCallback(async () => { - if (!session) return; - try { - const r = await fetch(`/api/me?userId=${session.id}`); - if (r.ok) { - const d = await r.json(); - setSession((s) => (s ? { ...s, balance_minutes: d.user.balance_minutes } : s)); - setVms(d.vms || []); - setGpuSku(d.gpu_sku || gpuSku); - setMaxVms(d.max_vms || 3); - } - const sr = await fetch(`/api/sessions?userId=${session.id}`); - if (sr.ok) setSessions(await sr.json()); - } catch (e) { console.error(e); } - }, [session, gpuSku]); + const [loading, setLoading] = useState(false); useEffect(() => { - if (!session) return; + const raw = localStorage.getItem('vortex_auth'); + if (raw) { try { const a = JSON.parse(raw); if (a?.token) setAuth(a); } catch {} } + }, []); + + const refresh = useCallback(async () => { + if (!auth) return; + try { + const r = await fetch('/api/me', { headers: { Authorization: `Bearer ${auth.token}` } }); + if (r.status === 401) { setAuth(null); localStorage.removeItem('vortex_auth'); return; } + if (r.ok) { + const d = await r.json(); + setVms(d.vms || []); + setSessions(d.sessions || []); + setGpuSku(d.gpu_sku || gpuSku); + setPrice(d.price_per_hour || 1); + setMaxMachines(d.max_machines === -1 ? 999 : d.max_machines || 3); + setFreeMachines(d.free_machines || 1); + setAuth((a) => (a ? { ...a, user: { ...a.user, ...d.user } } : a)); + } + } catch (e) { console.error(e); } + }, [auth?.token]); + + useEffect(() => { + if (!auth) return; refresh(); const t = setInterval(refresh, 5000); return () => clearInterval(t); - }, [session, refresh]); + }, [auth, refresh]); - useEffect(() => { - const raw = localStorage.getItem('vortex_session'); - if (raw) { try { const s = JSON.parse(raw); if (s?.id) setSession(s); } catch {} } - }, []); + if (!auth) return ; - const handleLogin = async (e: React.FormEvent) => { - e.preventDefault(); - if (!loginName.trim() || !loginPass) return; + const user = auth.user; + const hrs = Math.floor(user.balance_minutes / 60); + const mins = user.balance_minutes % 60; + const activeCount = [...vms, ...sessions].filter((r: any) => r.state === 'running' || r.state === 'provisioning').length; + const atCap = !user.unlimited && activeCount >= maxMachines; + + const api = (path: string, body?: any) => fetch(path, { + method: body ? 'POST' : 'GET', + headers: { Authorization: `Bearer ${auth.token}`, 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }); + + const deployVm = async (os: 'windows' | 'linux') => { setLoading(true); setError(''); try { - const r = await fetch('/api/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: loginName.trim(), password: loginPass }) }); + const r = await api('/api/vms/provision', { os }); const d = await r.json(); - if (r.ok) { setSession(d); localStorage.setItem('vortex_session', JSON.stringify(d)); } - else setError(d.error || 'failed'); + if (r.ok) refresh(); else setError(d.error || 'deploy failed'); } catch { setError('network error'); } finally { setLoading(false); } }; - const handleDeploy = async () => { - if (!session) return; + const spawnSession = async () => { setLoading(true); setError(''); try { - const r = await fetch('/api/vms/provision', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: session.id, os: deployOs, app: deployApp }) }); + const r = await api('/api/session/spawn', { resolution: '1440x900' }); const d = await r.json(); - if (r.ok) refresh(); - else setError(d.error || 'deploy failed'); + if (r.ok) refresh(); else setError(d.error || 'spawn failed'); } catch { setError('network error'); } finally { setLoading(false); } }; - const handleDestroy = async (vmId: string) => { - await fetch('/api/vms/destroy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: session!.id, vmId }) }); - refresh(); - }; - - const handleSpawnSession = async () => { - if (!session) return; - setLoading(true); setError(''); - try { - const r = await fetch('/api/session/spawn', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: session.id, resolution: '1440x900' }) }); - const d = await r.json(); - if (r.ok) refresh(); - else setError(d.error || 'spawn failed'); - } catch { setError('network error'); } finally { setLoading(false); } - }; - - const handleDestroySession = async (sessionId: string) => { - await fetch('/api/session/destroy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: session!.id, sessionId }) }); - refresh(); - }; - - if (!session) { - return ( -
-
-
-
- VORTEXGPU -
-

Rent a real GPU PC by the hour. Windows RDP or Linux SSH.

-
-
- - setLoginName(e.target.value)} placeholder="your username" autoComplete="username" className="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-cyan-300 text-sm outline-none focus:border-cyan-500" /> - - setLoginPass(e.target.value)} placeholder="your password" autoComplete="current-password" className="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-cyan-300 text-sm outline-none focus:border-cyan-500" /> - {error &&

{error}

} - -
-

$1.00 / hour · up to 3 machines · Bitcoin via BTCPay

-
-
- ); - } - - const hrs = Math.floor(session.balance_minutes / 60); - const mins = session.balance_minutes % 60; - const runningCount = vms.filter((v) => v.state === 'running' || v.state === 'provisioning').length; + const destroyVm = async (vmId: string) => { await api('/api/vms/destroy', { vmId }); refresh(); }; + const destroySession = async (sessionId: string) => { await api('/api/session/destroy', { sessionId }); refresh(); }; + const logout = () => { setAuth(null); localStorage.removeItem('vortex_auth'); }; return (
- {/* Header */}
@@ -166,14 +114,12 @@ export default function App() {
Balance
{hrs}h {mins}m
- + refresh()} />
-
@{session.username}{session.unlimited && }
-
{session.unlimited ? '∞ machines' : `${vms.length}/${maxVms} machines`}
+
@{user.username}{user.unlimited && }
+
{user.unlimited ? '∞ machines' : `${activeCount}/${maxMachines} machines · ${freeMachines} free`}
- +
@@ -186,120 +132,158 @@ export default function App() { )} - {/* Deploy panel */} -
-
-

Deploy a Machine

- {gpuSku} · ${1}/hr + {/* Products */} +
+
+

Products

+ {gpuSku} · ${price}/hr · first machine free
-
- - -
-
One-click App
-
- - -
-
+
+ } + name="Ubuntu GPU Session" + tag="In-browser · 4080 SUPER" + desc="Full Ubuntu desktop in your browser with the 4080 attached. Install anything, run any GPU job." + cta="⚡ Spawn Session" + onClick={spawnSession} + accent="emerald" + /> + } + name="Windows 10" + tag="RDP · full desktop" + desc="Real Windows 10 VM via RDP. Games, CUDA, GUI apps." + cta="Deploy Windows" + onClick={() => deployVm('windows')} + accent="cyan" + /> + } + name="Linux" + tag="SSH · headless compute" + desc="Debian VM over SSH. Headless compute, docker, servers." + cta="Deploy Linux" + onClick={() => deployVm('linux')} + accent="purple" + />
- + {atCap &&

Machine limit reached ({maxMachines} max). Stop one to deploy another.

}
- {/* Ubuntu GPU Sessions */} -
-
-

Ubuntu GPU Sessions

- RTX 4080 SUPER 16GB · in-browser desktop -
-

Spawn a full Ubuntu desktop in your browser with the 4080 attached (noVNC). Install anything, run any GPU job.

- - - {sessions.length > 0 && ( -
+ {/* Your resources */} +
+

Your Resources

+ {(vms.length === 0 && sessions.length === 0) ? ( +
+ +

Nothing running yet. Your first machine is free — spawn a session or deploy a VM above.

+
+ ) : ( +
{sessions.map((s) => ( -
-
+
+
- +
-
{s.instance_id}
-
{s.resolution} · node {s.node_hostname}:{s.port}
+
Ubuntu Session {s.instance_id}
+
{s.resolution} · node {s.node_hostname}:{s.port}
- {s.state} + +
+
+ + Proxy: + {s.proxy ? s.proxy : 'none yet (pool refreshing)'}
Open Desktop - +
VNC password: {s.password}
))} + {vms.map((vm) => ( +
+
+
+ {vm.os === 'windows' ? : } +
+
{vm.os === 'windows' ? 'Windows 10' : 'Linux'} #{vm.vm_id}
+
{vm.sku}
+
+
+ +
+ {vm.state === 'running' && ( +
+
+ {vm.os === 'windows' ? 'RDP' : 'SSH'} + 10.30.20.85:{vm.port} +
+
user{vm.username}
+
pass{vm.password}
+ {vm.app &&
app{vm.app}
} +
+ )} +
+ {(vm.state === 'running' || vm.state === 'provisioning') && ( + + )} +
+
+ ))}
)}
- {/* Machines */} - {vms.length === 0 ? ( -
- -

No machines yet. Deploy your first $1/hr GPU PC above.

-
- ) : ( -
- {vms.map((vm) => ( -
-
-
- {vm.os === 'windows' ? : } -
-
{vm.os === 'windows' ? 'Windows 10' : 'Linux'} #{vm.vm_id}
-
{vm.sku}
-
-
- {vm.state} -
- - {vm.state === 'running' && ( -
-
- {vm.os === 'windows' ? 'RDP' : 'SSH'} - host: {vm.os === 'windows' ? '10.30.20.85' : '10.30.20.85'}:{vm.port} -
-
user{vm.username}
-
pass{vm.password}
- {vm.app &&
app{vm.app}
} -
- )} - -
- {(vm.state === 'running' || vm.state === 'provisioning') && ( - - )} -
-
- ))} -
- )} +

+ ${price}/hr · {freeMachines} machine free · {maxMachines} max · Bitcoin via BTCPay · clean residential proxies on sessions +

- - {isPayOpen && setIsPayOpen(false)} onAdded={(m) => setSession((s) => (s ? { ...s, balance_minutes: s.balance_minutes + m } : s))} />}
); } -function PayModal({ userId, onClose, onAdded }: { userId: string; onClose: () => void; onAdded: (m: number) => void }) { +function StateBadge({ state }: { state: string }) { + const cls = state === 'running' ? 'bg-emerald-500/20 text-emerald-300' + : state === 'provisioning' ? 'bg-amber-500/20 text-amber-300' + : state === 'failed' ? 'bg-red-500/20 text-red-300' + : 'bg-zinc-800 text-zinc-400'; + return {state}; +} + +function ProductCard({ icon, name, tag, desc, cta, onClick, accent }: { + icon: React.ReactNode; name: string; tag: string; desc: string; cta: string; + onClick: () => void; accent: 'emerald' | 'cyan' | 'purple'; +}) { + const grad = accent === 'emerald' ? 'from-emerald-500 to-teal-600' + : accent === 'cyan' ? 'from-cyan-500 to-blue-600' + : 'from-purple-500 to-indigo-600'; + return ( +
+
{icon}{name}
+
{tag}
+

{desc}

+ +
+ ); +} + +function TopUpButton({ token, onAdded }: { token: string; onAdded: () => void }) { + const [open, setOpen] = useState(false); + return ( + <> + + {open && setOpen(false)} onAdded={onAdded} />} + + ); +} + +function PayModal({ token, onClose, onAdded }: { token: string; onClose: () => void; onAdded: () => void }) { const [hours, setHours] = useState(5); const [invoice, setInvoice] = useState(null); const [loading, setLoading] = useState(false); @@ -308,24 +292,20 @@ function PayModal({ userId, onClose, onAdded }: { userId: string; onClose: () => const gen = async () => { setLoading(true); try { - const r = await fetch('/api/btcpay/create-invoice', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId, usdAmount: hours }) }); + const r = await fetch('/api/btcpay/create-invoice', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ usdAmount: hours }) }); const d = await r.json(); if (r.ok) setInvoice(d); } catch (e) { console.error(e); } finally { setLoading(false); } }; - // poll settlement useEffect(() => { if (!invoice) return; const t = setInterval(async () => { - const r = await fetch(`/api/me?userId=${userId}`); - const d = await r.json(); - if (d.user.balance_minutes >= 0 && invoice.status === 'pending') { - // balance changed means settled — just close on next refresh - } + const r = await fetch('/api/me', { headers: { Authorization: `Bearer ${token}` } }); + if (r.ok) { const d = await r.json(); if (d.user.balance_minutes > 0) { onAdded(); onClose(); } } }, 5000); return () => clearInterval(t); - }, [invoice, userId]); + }, [invoice, token]); return (
@@ -363,3 +343,63 @@ function PayModal({ userId, onClose, onAdded }: { userId: string; onClose: () =>
); } + +function AuthGate({ onAuthed }: { onAuthed: (a: { token: string; user: User }) => void }) { + const [mode, setMode] = useState<'login' | 'register'>('login'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!username.trim() || !password) return; + setLoading(true); setError(''); + try { + const path = mode === 'login' ? '/api/auth/login' : '/api/auth/register'; + const r = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: username.trim(), password }) }); + const d = await r.json(); + if (r.ok) { + const a = { token: d.token, user: d.user }; + localStorage.setItem('vortex_auth', JSON.stringify(a)); + onAuthed(a); + } else setError(d.error || 'failed'); + } catch { setError('network error'); } finally { setLoading(false); } + }; + + return ( +
+
+
+
+ VORTEXGPU +
+

Rent a real GPU PC by the hour. Ubuntu sessions, Windows RDP, Linux SSH.

+
+ +
+ + +
+ +
+ + setUsername(e.target.value)} placeholder="your username" autoComplete="username" className="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-cyan-300 text-sm outline-none focus:border-cyan-500" /> + + setPassword(e.target.value)} placeholder={mode === 'register' ? 'min 6 characters' : 'your password'} autoComplete={mode === 'login' ? 'current-password' : 'new-password'} className="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-cyan-300 text-sm outline-none focus:border-cyan-500" /> + {error &&

{error}

} + +
+

$1.00 / hour · 1st machine free · up to 3 machines · Bitcoin via BTCPay

+
+
+ ); +} + +export default App; diff --git a/vortex-node-agent-linux.py b/vortex-node-agent-linux.py index 258be53..1a36048 100644 --- a/vortex-node-agent-linux.py +++ b/vortex-node-agent-linux.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 """ -VORTEX_GPU — Linux Host Node Agent (v2 — Ubuntu-session provisioning) +VORTEX_GPU — Linux Host Node Agent (v3 — Ubuntu-session provisioning + clean proxy) Target: Ubuntu (nightmare .128, RTX 4080 SUPER 16GB) Spawns in-browser Ubuntu desktop sessions, each with the 4080 attached (--gpus all): - provision_ubuntu : docker run a full Ubuntu LXDE desktop (noVNC) with GPU, on a dedicated port. Tenant gets a clean private machine; the physical - GPU is shared/hidden. + GPU is shared/hidden. If a clean ProxyFly proxy is supplied, its + address is injected as HTTP(S)_PROXY / ALL_PROXY env vars so the + session egresses through a clean residential IP automatically. - destroy_ubuntu : docker rm -f the session container. - shell / hashcat / comfyui : run an arbitrary command against the local GPU. @@ -110,18 +112,29 @@ def run_shell(command): return False, f"error: {e}" -def provision_ubuntu(instance_id, port, password, resolution): - """Spawn a full Ubuntu desktop session with the 4080 attached (noVNC on mapped port).""" +def provision_ubuntu(instance_id, port, password, resolution, proxy=None): + """Spawn a full Ubuntu desktop session with the 4080 attached (noVNC on mapped port). + + If a clean proxy string is supplied (e.g. http://1.2.3.4:8080), it is injected as + HTTP_PROXY/HTTPS_PROXY/ALL_PROXY env vars so outbound traffic egresses through it. + """ name = f"vortex-{instance_id}" _docker(["rm", "-f", name], timeout=30) # clean any stale instance - r = _docker(["run", "-d", "--gpus", "all", "--name", name, - "-p", f"{port}:80", - "-e", f"VNC_PASSWORD={password}", - "-e", f"RESOLUTION={resolution or '1440x900'}", - SESSION_IMAGE]) + args = ["run", "-d", "--gpus", "all", "--name", name, + "-p", f"{port}:80", + "-e", f"VNC_PASSWORD={password}", + "-e", f"RESOLUTION={resolution or '1440x900'}"] + if proxy: + args += ["-e", f"HTTP_PROXY={proxy}", "-e", f"http_proxy={proxy}", + "-e", f"HTTPS_PROXY={proxy}", "-e", f"https_proxy={proxy}", + "-e", f"ALL_PROXY={proxy}", "-e", f"all_proxy={proxy}", + "-e", "NO_PROXY=localhost,127.0.0.1"] + args.append(SESSION_IMAGE) + r = _docker(args) if r.returncode == 0: cid = r.stdout.strip()[:12] - return True, f"launched container={name} id={cid} port={port}" + prox = f" proxy={proxy}" if proxy else " proxy=none" + return True, f"launched container={name} id={cid} port={port}{prox}" return False, f"failed: {r.stderr.strip()[:400]}" @@ -141,7 +154,8 @@ def handle_job(job): ok, result = provision_ubuntu(payload.get("instanceId", "inst"), int(payload.get("port", 6090)), payload.get("password", "vortex"), - payload.get("resolution", "1440x900")) + payload.get("resolution", "1440x900"), + payload.get("proxy")) elif kind == "destroy_ubuntu": ok, result = destroy_ubuntu(payload.get("instanceId", "")) else: