Unified storefront + tiered pricing (1 free, 2&3 billed) + token auth + ProxyFly clean-proxy auto-assign on sessions
This commit is contained in:
289
server.ts
289
server.ts
@@ -3,6 +3,7 @@ import path from "path";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
|
import http from "http";
|
||||||
import { execFile } from "child_process";
|
import { execFile } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { createServer as createViteServer } from "vite";
|
import { createServer as createViteServer } from "vite";
|
||||||
@@ -14,14 +15,15 @@ const exec = promisify(execFile);
|
|||||||
/**
|
/**
|
||||||
* VortexGPU — rent-a-PC platform (production build)
|
* VortexGPU — rent-a-PC platform (production build)
|
||||||
*
|
*
|
||||||
* Real virtualization: drives Proxmox via `qm` over SSH to clone real KVM VMs.
|
* Products (unified storefront):
|
||||||
* - Windows 10 (RDP) : clone template 504 (comandoVM, template:1)
|
* - Ubuntu GPU Session (in-browser desktop, 4080 SUPER attached, noVNC)
|
||||||
* - Linux (SSH) : clone template 990 (vortex-linux-tpl, debian cloud-init)
|
* - Windows 10 (RDP) : clone template 504 (comandoVM)
|
||||||
* GPU offload: hashcat/comfyui jobs dispatch to the Windows GPU hosts (.128 4080S,
|
* - Linux (SSH) : clone template 990 (vortex-linux-tpl)
|
||||||
* .186 3070) over LAN — the shared GPU pool is hidden from tenants.
|
* Pricing: $1/hr flat. FIRST machine free per account; the 2nd and 3rd bill.
|
||||||
* Per-tenant access: each VM gets a dedicated RDP/SSH port + subdomain (real
|
* Sessions are metered exactly like VMs (no more free sessions).
|
||||||
* per-tenant entry; true rotating-residential IPs would need a proxy provider).
|
* Auth: token-based register/login/logout (everyone gets their own account).
|
||||||
* Billing: $1/hr flat, up to 3 VMs per user, auto-stop at zero balance.
|
* 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).
|
* Payments: BTCPay (real invoices + HMAC-signed webhook settlement).
|
||||||
* Admin: /admin?token= (404 without token) + /api/admin/* (Bearer).
|
* 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 WEBHOOK_SECRET = process.env.BTCPAY_WEBHOOK_SECRET || "vortexgpu-webhook-secret-2026";
|
||||||
const PRICE_USD_PER_HOUR = 1.0;
|
const PRICE_USD_PER_HOUR = 1.0;
|
||||||
const MAX_VMS_PER_USER = 3;
|
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.
|
// Marketing tier label (what tenants see) — configurable, decoupled from truth.
|
||||||
const GPU_SKU = process.env.GPU_SKU || "NVIDIA GeForce RTX 4080 SUPER 16GB";
|
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,
|
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||||
instance_id TEXT NOT NULL UNIQUE, node_hostname 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,
|
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
|
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) {
|
function ensureColumn(table: string, col: string, ddl: string) {
|
||||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map((c: any) => c.name);
|
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}`);
|
if (!cols.includes(col)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
||||||
}
|
}
|
||||||
ensureColumn("users", "password_hash", "password_hash TEXT");
|
ensureColumn("users", "password_hash", "password_hash TEXT");
|
||||||
ensureColumn("users", "unlimited", "unlimited INTEGER NOT NULL DEFAULT 0");
|
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 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; }
|
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 {
|
function nextVmid(): number {
|
||||||
// pick the highest existing VMID below our floor, or the floor itself
|
const row = one<{ max_id: number | null }>("SELECT MAX(vm_id) as max_id FROM vms WHERE vm_id >= ?", PVE_VMID_START);
|
||||||
return 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 }> {
|
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 —
|
// 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
|
// the 3GB Linux clone is quick, Windows 250GB needs ~5-10 min.
|
||||||
// task as detached via `setsid` so the gateway SSH session isn't the lifeline.
|
|
||||||
const r = await pve(["qm", "clone", String(template), String(vmid), "--name", name, "--full"]);
|
const r = await pve(["qm", "clone", String(template), String(vmid), "--name", name, "--full"]);
|
||||||
return r;
|
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 nodeAuthorized(req: express.Request) { return req.headers["x-node-secret"] === NODE_SECRET; }
|
||||||
function adminAuthorized(req: express.Request) { return (req.headers["authorization"] || "") === `Bearer ${ADMIN_TOKEN}`; }
|
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 {
|
function clientIp(req: express.Request): string {
|
||||||
const fwd = req.headers["x-forwarded-for"];
|
const fwd = req.headers["x-forwarded-for"];
|
||||||
const raw = (Array.isArray(fwd) ? fwd[0] : fwd)?.toString().split(",")[0].trim() || req.socket.remoteAddress || "";
|
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",
|
status: "ok", node: "VortexGPU",
|
||||||
gpuNodesOnline: online.length, gpuNodesTotal: Object.keys(nodes).length,
|
gpuNodesOnline: online.length, gpuNodesTotal: Object.keys(nodes).length,
|
||||||
gpuSku: GPU_SKU, priceUsdPerHour: PRICE_USD_PER_HOUR, maxVmsPerUser: MAX_VMS_PER_USER,
|
gpuSku: GPU_SKU, priceUsdPerHour: PRICE_USD_PER_HOUR, maxVmsPerUser: MAX_VMS_PER_USER,
|
||||||
|
freeMachines: FREE_MACHINES,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Login / register. Requires username + password. Existing accounts must
|
app.get("/api/proxy/pool", (_req, res) => {
|
||||||
// match their stored password; new accounts are created on first login.
|
res.json({ count: proxyPool.length, proxies: proxyPool.slice(0, 20).map((p) => ({ ip: p.ip, location: p.location, latencyMs: p.latencyMs, anonymity: p.anonymity })) });
|
||||||
app.post("/api/session", (req, res) => {
|
});
|
||||||
const username = str(req.body?.username, "").slice(0, 32);
|
|
||||||
|
// ===== 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, "");
|
const password = str(req.body?.password, "");
|
||||||
if (!username) return res.status(400).json({ error: "username required" });
|
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);
|
const id = "usr_" + crypto.randomBytes(8).toString("hex");
|
||||||
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 (?,?,?,?,?,?,?)",
|
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);
|
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({
|
res.json({ token: issueToken(id), user: publicUser(user) });
|
||||||
id: user.id, username: user.username,
|
|
||||||
balance_minutes: user.balance_minutes,
|
|
||||||
unlimited: !!user.unlimited, is_admin: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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) => {
|
app.get("/api/me", (req, res) => {
|
||||||
const userId = str(req.query.userId, "");
|
const user = userFromReq(req);
|
||||||
if (!userId) return res.status(400).json({ error: "userId required" });
|
if (!user) return res.status(401).json({ error: "not authenticated" });
|
||||||
const user = one<any>("SELECT * FROM users WHERE id=?", userId);
|
const vms = all<any>("SELECT * FROM vms WHERE user_id=? ORDER BY created_at DESC", user.id);
|
||||||
if (!user) return res.status(404).json({ error: "not found" });
|
const sessions = all<any>("SELECT * FROM sessions WHERE user_id=? ORDER BY created_at DESC", user.id);
|
||||||
const vms = all<any>("SELECT * FROM vms WHERE user_id=? ORDER BY created_at DESC", userId);
|
res.json({
|
||||||
res.json({ user: { ...user, password_hash: undefined }, vms, max_vms: user.unlimited ? -1 : MAX_VMS_PER_USER, gpu_sku: GPU_SKU });
|
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) =====
|
// ===== VM PROVISIONING (real KVM clone) =====
|
||||||
app.post("/api/vms/provision", async (req, res) => {
|
app.post("/api/vms/provision", async (req, res) => {
|
||||||
const { userId, os, app } = req.body || {};
|
const { os, app } = req.body || {};
|
||||||
const user = one<any>("SELECT * FROM users WHERE id=?", str(userId, ""));
|
const user = userFromReq(req);
|
||||||
if (!user) return res.status(400).json({ error: "invalid user" });
|
if (!user) return res.status(401).json({ error: "not authenticated" });
|
||||||
const unlimited = !!user.unlimited;
|
const unlimited = !!user.unlimited;
|
||||||
if (!unlimited && user.balance_minutes <= 0) return res.status(402).json({ error: "insufficient balance — top up with Bitcoin" });
|
const active = countActive(user.id);
|
||||||
const myVms = all<any>("SELECT id FROM vms WHERE user_id=? AND state NOT IN ('destroyed','stopped')", 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 && myVms.length >= MAX_VMS_PER_USER) return res.status(429).json({ error: `limit reached — max ${MAX_VMS_PER_USER} machines per account` });
|
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 isWin = (os || "windows") === "windows";
|
||||||
const template = isWin ? PVE_TEMPLATE_WIN : PVE_TEMPLATE_LINUX;
|
const template = isWin ? PVE_TEMPLATE_WIN : PVE_TEMPLATE_LINUX;
|
||||||
const vmid = nextVmid() + Math.floor(Math.random() * 1000);
|
const vmid = nextVmid() + Math.floor(Math.random() * 1000);
|
||||||
const vmUid = "vm_" + crypto.randomBytes(6).toString("hex");
|
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 name = isWin ? `vortex-win-${vmid}` : `vortex-lin-${vmid}`;
|
||||||
const username = isWin ? "administrator" : "rent";
|
const username = isWin ? "administrator" : "rent";
|
||||||
const password = "Vx" + crypto.randomBytes(6).toString("hex") + "!";
|
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 (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
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());
|
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) => {
|
cloneVm(template, vmid, name).then(async (r) => {
|
||||||
if (!r.ok) { q("UPDATE vms SET state='failed' WHERE id=?", vmUid); return; }
|
if (!r.ok) { q("UPDATE vms SET state='failed' WHERE id=?", vmUid); return; }
|
||||||
const s = await startVm(vmid);
|
const s = await startVm(vmid);
|
||||||
@@ -353,8 +459,10 @@ async function startServer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/vms/destroy", async (req, res) => {
|
app.post("/api/vms/destroy", async (req, res) => {
|
||||||
const { userId, vmId } = req.body || {};
|
const { vmId } = req.body || {};
|
||||||
const vm = one<any>("SELECT * FROM vms WHERE id=? AND user_id=?", str(vmId, ""), str(userId, ""));
|
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" });
|
if (!vm) return res.status(404).json({ error: "not found" });
|
||||||
q("UPDATE vms SET state='stopping' WHERE id=?", vm.id);
|
q("UPDATE vms SET state='stopping' WHERE id=?", vm.id);
|
||||||
await stopVm(vm.vm_id);
|
await stopVm(vm.vm_id);
|
||||||
@@ -364,11 +472,11 @@ async function startServer() {
|
|||||||
|
|
||||||
// ===== BTCPAY =====
|
// ===== BTCPAY =====
|
||||||
app.post("/api/btcpay/create-invoice", async (req, res) => {
|
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 amountUsd = Math.max(1, Number(usdAmount) || 5);
|
||||||
const minutes = Math.round(amountUsd / PRICE_USD_PER_HOUR * 60);
|
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" });
|
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`, {
|
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) => {
|
app.post("/api/btcpay/webhook", (req, res) => {
|
||||||
const sig = req.headers["btcpay-sig"] as string;
|
const sig = req.headers["btcpay-sig"] as string;
|
||||||
if (sig) {
|
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 raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from(JSON.stringify(req.body));
|
||||||
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(raw).digest("hex");
|
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 !== `sha256=${expected}`) return res.status(401).json({ error: "bad signature" });
|
||||||
}
|
|
||||||
let payload: any = {};
|
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" }); }
|
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) =====
|
// ===== UBUNTU GPU SESSIONS (spawn in-browser desktop with the 4080 attached) =====
|
||||||
app.post("/api/session/spawn", (req, res) => {
|
app.post("/api/session/spawn", (req, res) => {
|
||||||
const { userId, resolution } = req.body || {};
|
const { resolution } = req.body || {};
|
||||||
const user = one<any>("SELECT * FROM users WHERE id=?", str(userId, ""));
|
const user = userFromReq(req);
|
||||||
if (!user) return res.status(400).json({ error: "invalid user" });
|
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.
|
// Target the Linux GPU node (nightmare) that runs the Ubuntu-session agent.
|
||||||
const hostname = "nightmare";
|
const hostname = "nightmare";
|
||||||
@@ -421,22 +532,25 @@ async function startServer() {
|
|||||||
const password = "Ub" + crypto.randomBytes(6).toString("hex") + "!";
|
const password = "Ub" + crypto.randomBytes(6).toString("hex") + "!";
|
||||||
const reso = str(resolution, "1440x900");
|
const reso = str(resolution, "1440x900");
|
||||||
const id = "ses_" + crypto.randomBytes(8).toString("hex");
|
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 (?,?,?,?,?,?,?,?,?,?)",
|
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, "provisioning", Date.now());
|
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 });
|
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) => {
|
app.get("/api/sessions", (req, res) => {
|
||||||
const userId = str(req.query.userId, "");
|
const user = userFromReq(req);
|
||||||
if (!userId) return res.status(400).json({ error: "userId required" });
|
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", userId));
|
res.json(all<any>("SELECT * FROM sessions WHERE user_id=? ORDER BY created_at DESC", user.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/session/destroy", (req, res) => {
|
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" });
|
if (!sess) return res.status(404).json({ error: "not found" });
|
||||||
q("UPDATE sessions SET state='stopping' WHERE id=?", sess.id);
|
q("UPDATE sessions SET state='stopping' WHERE id=?", sess.id);
|
||||||
dispatchJob(sess.node_hostname, "destroy_ubuntu", "", { instanceId: sess.instance_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"),
|
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"),
|
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"),
|
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,
|
adminToken: ADMIN_TOKEN,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -524,22 +639,30 @@ async function startServer() {
|
|||||||
res.json({ ok: true });
|
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(() => {
|
setInterval(() => {
|
||||||
try {
|
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>();
|
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 r of runningVms) perUser.set(r.user_id, (perUser.get(r.user_id) || 0) + 1);
|
||||||
for (const [userId, count] of perUser) {
|
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);
|
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
|
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);
|
const u = one<any>("SELECT balance_minutes FROM users WHERE id=?", userId);
|
||||||
if (u && u.balance_minutes <= 0) {
|
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);
|
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));
|
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); }
|
} 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", () => {
|
const server = app.listen(PORT, "0.0.0.0", () => {
|
||||||
console.log(`[VortexGPU] rent-a-PC gateway on :${PORT}`);
|
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] 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);
|
server.on("upgrade", sessionProxy.upgrade);
|
||||||
}
|
}
|
||||||
|
|||||||
444
src/App.tsx
444
src/App.tsx
@@ -1,161 +1,109 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Monitor, Cpu, Plus, Power, Play, Clock, Shield, Terminal, Zap,
|
Monitor, Cpu, Plus, Power, Clock, Shield, Terminal, Zap,
|
||||||
Bitcoin, Laptop, Server, KeyRound, Hash, Sparkles, LogOut, X, ExternalLink, Copy,
|
Bitcoin, Laptop, Server, KeyRound, Sparkles, LogOut, X, ExternalLink, Copy, Globe, UserPlus, LogIn,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VortexGPU — rent-a-PC frontend.
|
* VortexGPU — rent-a-PC storefront.
|
||||||
* Fund with Bitcoin ($1/hr), deploy a Windows (RDP) or Linux (SSH) machine,
|
* Unified products: Ubuntu GPU Session (in-browser 4080), Windows RDP, Linux SSH.
|
||||||
* one-click Hashcat/ComfyUI, up to 3 machines per account.
|
* Pricing: $1/hr. First machine FREE, 2nd & 3rd billed. Bitcoin via BTCPay.
|
||||||
|
* Token auth: register / login / logout — everyone gets their own account.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface ApiVm {
|
interface ApiVm {
|
||||||
id: string;
|
id: string; vm_id: number; os: string; sku: string; state: string;
|
||||||
vm_id: number;
|
port: number | null; username: string | null; password: string | null;
|
||||||
os: string;
|
app: string | null; created_at: number;
|
||||||
sku: string;
|
|
||||||
state: string;
|
|
||||||
port: number | null;
|
|
||||||
username: string | null;
|
|
||||||
password: string | null;
|
|
||||||
app: string | null;
|
|
||||||
created_at: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiSession {
|
interface ApiSession {
|
||||||
id: string;
|
id: string; instance_id: string; node_hostname: string; port: number;
|
||||||
instance_id: string;
|
password: string; resolution: string; proxy: string | null; state: string; created_at: number;
|
||||||
node_hostname: string;
|
|
||||||
port: number;
|
|
||||||
password: string;
|
|
||||||
resolution: string;
|
|
||||||
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() {
|
function App() {
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
const [auth, setAuth] = useState<{ token: string; user: User } | null>(null);
|
||||||
const [vms, setVms] = useState<ApiVm[]>([]);
|
const [vms, setVms] = useState<ApiVm[]>([]);
|
||||||
const [sessions, setSessions] = useState<ApiSession[]>([]);
|
const [sessions, setSessions] = useState<ApiSession[]>([]);
|
||||||
const [gpuSku, setGpuSku] = useState('NVIDIA GeForce RTX 4080 SUPER 16GB');
|
const [gpuSku, setGpuSku] = useState('NVIDIA GeForce RTX 4080 SUPER 16GB');
|
||||||
const [maxVms, setMaxVms] = useState(3);
|
const [price, setPrice] = useState(1);
|
||||||
const [loginName, setLoginName] = useState('');
|
const [maxMachines, setMaxMachines] = useState(3);
|
||||||
const [loginPass, setLoginPass] = useState('');
|
const [freeMachines, setFreeMachines] = useState(1);
|
||||||
const [isPayOpen, setIsPayOpen] = useState(false);
|
|
||||||
const [deployOs, setDeployOs] = useState<'windows' | 'linux'>('windows');
|
|
||||||
const [deployApp, setDeployApp] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
refresh();
|
||||||
const t = setInterval(refresh, 5000);
|
const t = setInterval(refresh, 5000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [session, refresh]);
|
}, [auth, refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
if (!auth) return <AuthGate onAuthed={setAuth} />;
|
||||||
const raw = localStorage.getItem('vortex_session');
|
|
||||||
if (raw) { try { const s = JSON.parse(raw); if (s?.id) setSession(s); } catch {} }
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const user = auth.user;
|
||||||
e.preventDefault();
|
const hrs = Math.floor(user.balance_minutes / 60);
|
||||||
if (!loginName.trim() || !loginPass) return;
|
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('');
|
setLoading(true); setError('');
|
||||||
try {
|
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();
|
const d = await r.json();
|
||||||
if (r.ok) { setSession(d); localStorage.setItem('vortex_session', JSON.stringify(d)); }
|
if (r.ok) refresh(); else setError(d.error || 'deploy failed');
|
||||||
else setError(d.error || 'failed');
|
|
||||||
} catch { setError('network error'); } finally { setLoading(false); }
|
} catch { setError('network error'); } finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeploy = async () => {
|
const spawnSession = async () => {
|
||||||
if (!session) return;
|
|
||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
try {
|
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();
|
const d = await r.json();
|
||||||
if (r.ok) refresh();
|
if (r.ok) refresh(); else setError(d.error || 'spawn failed');
|
||||||
else setError(d.error || 'deploy failed');
|
|
||||||
} catch { setError('network error'); } finally { setLoading(false); }
|
} catch { setError('network error'); } finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDestroy = async (vmId: string) => {
|
const destroyVm = async (vmId: string) => { await api('/api/vms/destroy', { vmId }); refresh(); };
|
||||||
await fetch('/api/vms/destroy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: session!.id, vmId }) });
|
const destroySession = async (sessionId: string) => { await api('/api/session/destroy', { sessionId }); refresh(); };
|
||||||
refresh();
|
const logout = () => { setAuth(null); localStorage.removeItem('vortex_auth'); };
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="min-h-screen bg-[#05070d] flex items-center justify-center p-6 font-sans">
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="inline-flex items-center gap-2 text-3xl font-black tracking-tight text-white">
|
|
||||||
<span className="text-cyan-400">VORTEX</span>GPU
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-zinc-400 mt-2">Rent a real GPU PC by the hour. Windows RDP or Linux SSH.</p>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleLogin} className="bg-zinc-900/50 border border-zinc-800 rounded-2xl p-6 space-y-4 backdrop-blur">
|
|
||||||
<label className="block text-xs text-zinc-400">USERNAME:</label>
|
|
||||||
<input type="text" value={loginName} onChange={(e) => 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" />
|
|
||||||
<label className="block text-xs text-zinc-400">PASSWORD:</label>
|
|
||||||
<input type="password" value={loginPass} onChange={(e) => 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 && <p className="text-xs text-red-400">{error}</p>}
|
|
||||||
<button disabled={loading} className="w-full py-3 bg-gradient-to-r from-cyan-500 to-blue-600 text-black font-bold rounded-xl text-sm disabled:opacity-50">
|
|
||||||
{loading ? 'Signing in...' : 'Login / Register'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<p className="text-[11px] text-zinc-600 text-center mt-4">$1.00 / hour · up to 3 machines · Bitcoin via BTCPay</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#05070d] text-zinc-100 font-sans">
|
<div className="min-h-screen bg-[#05070d] text-zinc-100 font-sans">
|
||||||
{/* Header */}
|
|
||||||
<header className="border-b border-zinc-800/70 bg-zinc-950/60 backdrop-blur sticky top-0 z-40">
|
<header className="border-b border-zinc-800/70 bg-zinc-950/60 backdrop-blur sticky top-0 z-40">
|
||||||
<div className="max-w-6xl mx-auto px-5 py-3 flex items-center justify-between">
|
<div className="max-w-6xl mx-auto px-5 py-3 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 font-black tracking-tight text-white text-lg">
|
<div className="flex items-center gap-2 font-black tracking-tight text-white text-lg">
|
||||||
@@ -166,14 +114,12 @@ export default function App() {
|
|||||||
<div className="text-[11px] text-zinc-500 uppercase tracking-wide">Balance</div>
|
<div className="text-[11px] text-zinc-500 uppercase tracking-wide">Balance</div>
|
||||||
<div className="text-sm font-bold text-amber-300">{hrs}h {mins}m</div>
|
<div className="text-sm font-bold text-amber-300">{hrs}h {mins}m</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setIsPayOpen(true)} className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-500/20 border border-amber-500/40 text-amber-300 rounded-lg text-xs font-bold hover:bg-amber-500/30">
|
<TopUpButton token={auth.token} onAdded={() => refresh()} />
|
||||||
<Bitcoin className="w-3.5 h-3.5" /> Top Up
|
|
||||||
</button>
|
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-cyan-300 font-bold text-sm">@{session.username}{session.unlimited && <span className="text-amber-400" title="Unlimited machines"> ∞</span>}</div>
|
<div className="text-cyan-300 font-bold text-sm">@{user.username}{user.unlimited && <span className="text-amber-400" title="Unlimited machines"> ∞</span>}</div>
|
||||||
<div className="text-[10px] text-zinc-500">{session.unlimited ? '∞ machines' : `${vms.length}/${maxVms} machines`}</div>
|
<div className="text-[10px] text-zinc-500">{user.unlimited ? '∞ machines' : `${activeCount}/${maxMachines} machines · ${freeMachines} free`}</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => { setSession(null); localStorage.removeItem('vortex_session'); }} className="p-2 text-zinc-500 hover:text-white rounded-lg border border-zinc-800" title="Logout"><LogOut className="w-4 h-4" /></button>
|
<button onClick={logout} className="p-2 text-zinc-500 hover:text-white rounded-lg border border-zinc-800" title="Logout"><LogOut className="w-4 h-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -186,120 +132,158 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Deploy panel */}
|
{/* Products */}
|
||||||
<div className="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-5">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="font-bold text-lg flex items-center gap-2"><Plus className="w-5 h-5 text-cyan-400" /> Deploy a Machine</h2>
|
|
||||||
<span className="text-xs text-zinc-500">{gpuSku} · ${1}/hr</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
||||||
<button onClick={() => setDeployOs('windows')} className={`p-4 rounded-xl border text-left transition ${deployOs === 'windows' ? 'border-cyan-500 bg-cyan-500/10' : 'border-zinc-800 hover:border-zinc-700'}`}>
|
|
||||||
<div className="flex items-center gap-2 font-bold"><Laptop className="w-5 h-5 text-cyan-400" /> Windows 10</div>
|
|
||||||
<div className="text-[11px] text-zinc-500 mt-1">RDP access · full desktop · games + CUDA</div>
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setDeployOs('linux')} className={`p-4 rounded-xl border text-left transition ${deployOs === 'linux' ? 'border-cyan-500 bg-cyan-500/10' : 'border-zinc-800 hover:border-zinc-700'}`}>
|
|
||||||
<div className="flex items-center gap-2 font-bold"><Server className="w-5 h-5 text-emerald-400" /> Linux</div>
|
|
||||||
<div className="text-[11px] text-zinc-500 mt-1">SSH access · debian · headless compute</div>
|
|
||||||
</button>
|
|
||||||
<div className="p-4 rounded-xl border border-zinc-800">
|
|
||||||
<div className="font-bold text-sm mb-2 flex items-center gap-2"><Sparkles className="w-4 h-4 text-purple-400" /> One-click App</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={() => setDeployApp(deployApp === 'hashcat' ? '' : 'hashcat')} className={`flex-1 py-1.5 rounded-lg border text-xs font-bold ${deployApp === 'hashcat' ? 'border-purple-500 bg-purple-500/15 text-purple-300' : 'border-zinc-700 text-zinc-400'}`}><Hash className="w-3 h-3 inline" /> Hashcat</button>
|
|
||||||
<button onClick={() => setDeployApp(deployApp === 'comfyui' ? '' : 'comfyui')} className={`flex-1 py-1.5 rounded-lg border text-xs font-bold ${deployApp === 'comfyui' ? 'border-purple-500 bg-purple-500/15 text-purple-300' : 'border-zinc-700 text-zinc-400'}`}><Sparkles className="w-3 h-3 inline" /> ComfyUI</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={handleDeploy} disabled={loading || (!session.unlimited && runningCount >= maxVms)} className="mt-4 w-full py-3 bg-gradient-to-r from-cyan-500 to-blue-600 text-black font-bold rounded-xl text-sm disabled:opacity-40 disabled:cursor-not-allowed">
|
|
||||||
{loading ? 'Provisioning...' : (!session.unlimited && runningCount >= maxVms) ? `Limit reached (${maxVms} max)` : `Deploy ${deployOs === 'windows' ? 'Windows' : 'Linux'} Machine — $1/hr`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Ubuntu GPU Sessions */}
|
|
||||||
<div className="bg-zinc-900/40 border border-emerald-800/40 rounded-2xl p-5">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h2 className="font-bold text-lg flex items-center gap-2"><Terminal className="w-5 h-5 text-emerald-400" /> Ubuntu GPU Sessions</h2>
|
|
||||||
<span className="text-xs text-zinc-500">RTX 4080 SUPER 16GB · in-browser desktop</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-zinc-500 mb-4">Spawn a full Ubuntu desktop in your browser with the 4080 attached (noVNC). Install anything, run any GPU job.</p>
|
|
||||||
<button onClick={handleSpawnSession} disabled={loading} className="w-full py-3 bg-gradient-to-r from-emerald-500 to-teal-600 text-black font-bold rounded-xl text-sm disabled:opacity-40">
|
|
||||||
{loading ? 'Spawning...' : '⚡ Spawn Ubuntu Session'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{sessions.length > 0 && (
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-3">
|
|
||||||
{sessions.map((s) => (
|
|
||||||
<div key={s.id} className="bg-black/40 border border-zinc-800 rounded-xl p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Terminal className="w-4 h-4 text-emerald-400" />
|
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold text-sm">{s.instance_id}</div>
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="text-[10px] text-zinc-500">{s.resolution} · node {s.node_hostname}:{s.port}</div>
|
<h2 className="font-bold text-lg">Products</h2>
|
||||||
|
<span className="text-xs text-zinc-500">{gpuSku} · ${price}/hr · first machine free</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<ProductCard
|
||||||
|
icon={<Terminal className="w-7 h-7 text-emerald-400" />}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<ProductCard
|
||||||
|
icon={<Laptop className="w-7 h-7 text-cyan-400" />}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<ProductCard
|
||||||
|
icon={<Server className="w-7 h-7 text-purple-400" />}
|
||||||
|
name="Linux"
|
||||||
|
tag="SSH · headless compute"
|
||||||
|
desc="Debian VM over SSH. Headless compute, docker, servers."
|
||||||
|
cta="Deploy Linux"
|
||||||
|
onClick={() => deployVm('linux')}
|
||||||
|
accent="purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{atCap && <p className="mt-2 text-[11px] text-amber-400">Machine limit reached ({maxMachines} max). Stop one to deploy another.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Your resources */}
|
||||||
|
<div>
|
||||||
|
<h2 className="font-bold text-lg mb-3">Your Resources</h2>
|
||||||
|
{(vms.length === 0 && sessions.length === 0) ? (
|
||||||
|
<div className="text-center py-16 border border-dashed border-zinc-800 rounded-2xl">
|
||||||
|
<Monitor className="w-12 h-12 mx-auto text-zinc-700 mb-3" />
|
||||||
|
<p className="text-zinc-500">Nothing running yet. Your first machine is free — spawn a session or deploy a VM above.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<div key={s.id} className="bg-zinc-900/40 border border-emerald-800/40 rounded-2xl p-5">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Terminal className="w-5 h-5 text-emerald-400" />
|
||||||
|
<div>
|
||||||
|
<div className="font-bold">Ubuntu Session <span className="text-[10px] text-zinc-500">{s.instance_id}</span></div>
|
||||||
|
<div className="text-[11px] text-zinc-500">{s.resolution} · node {s.node_hostname}:{s.port}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase ${s.state === 'running' ? 'bg-emerald-500/20 text-emerald-300' : s.state === 'provisioning' ? 'bg-amber-500/20 text-amber-300' : s.state === 'failed' ? 'bg-red-500/20 text-red-300' : 'bg-zinc-800 text-zinc-400'}`}>{s.state}</span>
|
<StateBadge state={s.state} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2 text-[11px]">
|
||||||
|
<Globe className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span className="text-zinc-500">Proxy:</span>
|
||||||
|
<span className="text-cyan-300 font-mono">{s.proxy ? s.proxy : 'none yet (pool refreshing)'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
<a href={`/session/${s.instance_id}/`} target="_blank" rel="noopener noreferrer" className="flex-1 text-center py-2 bg-emerald-500 text-black font-bold rounded-lg text-xs"><ExternalLink className="w-3.5 h-3.5 inline" /> Open Desktop</a>
|
<a href={`/session/${s.instance_id}/`} target="_blank" rel="noopener noreferrer" className="flex-1 text-center py-2 bg-emerald-500 text-black font-bold rounded-lg text-xs"><ExternalLink className="w-3.5 h-3.5 inline" /> Open Desktop</a>
|
||||||
<button onClick={() => handleDestroySession(s.id)} className="px-3 py-2 border border-red-500/40 text-red-300 rounded-lg text-xs font-bold hover:bg-red-500/10">Stop</button>
|
<button onClick={() => destroySession(s.id)} className="px-3 py-2 border border-red-500/40 text-red-300 rounded-lg text-xs font-bold hover:bg-red-500/10">Stop</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-[10px] text-zinc-500 font-mono">VNC password: <span className="text-amber-300">{s.password}</span></div>
|
<div className="mt-2 text-[10px] text-zinc-500 font-mono">VNC password: <span className="text-amber-300">{s.password}</span></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Machines */}
|
|
||||||
{vms.length === 0 ? (
|
|
||||||
<div className="text-center py-16 border border-dashed border-zinc-800 rounded-2xl">
|
|
||||||
<Monitor className="w-12 h-12 mx-auto text-zinc-700 mb-3" />
|
|
||||||
<p className="text-zinc-500">No machines yet. Deploy your first $1/hr GPU PC above.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{vms.map((vm) => (
|
{vms.map((vm) => (
|
||||||
<div key={vm.id} className="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-5">
|
<div key={vm.id} className="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-5">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{vm.os === 'windows' ? <Laptop className="w-5 h-5 text-cyan-400" /> : <Server className="w-5 h-5 text-emerald-400" />}
|
{vm.os === 'windows' ? <Laptop className="w-5 h-5 text-cyan-400" /> : <Server className="w-5 h-5 text-purple-400" />}
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold">{vm.os === 'windows' ? 'Windows 10' : 'Linux'} <span className="text-[10px] text-zinc-500">#{vm.vm_id}</span></div>
|
<div className="font-bold">{vm.os === 'windows' ? 'Windows 10' : 'Linux'} <span className="text-[10px] text-zinc-500">#{vm.vm_id}</span></div>
|
||||||
<div className="text-[11px] text-zinc-500">{vm.sku}</div>
|
<div className="text-[11px] text-zinc-500">{vm.sku}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase ${vm.state === 'running' ? 'bg-emerald-500/20 text-emerald-300' : vm.state === 'provisioning' ? 'bg-amber-500/20 text-amber-300' : vm.state === 'failed' ? 'bg-red-500/20 text-red-300' : 'bg-zinc-800 text-zinc-400'}`}>{vm.state}</span>
|
<StateBadge state={vm.state} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{vm.state === 'running' && (
|
{vm.state === 'running' && (
|
||||||
<div className="mt-4 p-3 bg-black/50 rounded-xl border border-zinc-800 space-y-1.5 font-mono text-xs">
|
<div className="mt-4 p-3 bg-black/50 rounded-xl border border-zinc-800 space-y-1.5 font-mono text-xs">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-zinc-500 flex items-center gap-1.5"><KeyRound className="w-3.5 h-3.5" /> {vm.os === 'windows' ? 'RDP' : 'SSH'}</span>
|
<span className="text-zinc-500 flex items-center gap-1.5"><KeyRound className="w-3.5 h-3.5" /> {vm.os === 'windows' ? 'RDP' : 'SSH'}</span>
|
||||||
<span className="text-cyan-300">host: {vm.os === 'windows' ? '10.30.20.85' : '10.30.20.85'}:{vm.port}</span>
|
<span className="text-cyan-300">10.30.20.85:{vm.port}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between"><span className="text-zinc-500">user</span><span className="text-amber-300">{vm.username}</span></div>
|
<div className="flex items-center justify-between"><span className="text-zinc-500">user</span><span className="text-amber-300">{vm.username}</span></div>
|
||||||
<div className="flex items-center justify-between"><span className="text-zinc-500">pass</span><span className="text-amber-300">{vm.password}</span></div>
|
<div className="flex items-center justify-between"><span className="text-zinc-500">pass</span><span className="text-amber-300">{vm.password}</span></div>
|
||||||
{vm.app && <div className="flex items-center justify-between"><span className="text-zinc-500">app</span><span className="text-purple-300">{vm.app}</span></div>}
|
{vm.app && <div className="flex items-center justify-between"><span className="text-zinc-500">app</span><span className="text-purple-300">{vm.app}</span></div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4">
|
||||||
{(vm.state === 'running' || vm.state === 'provisioning') && (
|
{(vm.state === 'running' || vm.state === 'provisioning') && (
|
||||||
<button onClick={() => handleDestroy(vm.id)} className="flex-1 py-2 border border-red-500/40 text-red-300 rounded-lg text-xs font-bold hover:bg-red-500/10"><Power className="w-3.5 h-3.5 inline" /> Stop</button>
|
<button onClick={() => destroyVm(vm.id)} className="flex-1 py-2 border border-red-500/40 text-red-300 rounded-lg text-xs font-bold hover:bg-red-500/10"><Power className="w-3.5 h-3.5 inline" /> Stop</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</div>
|
||||||
|
|
||||||
{isPayOpen && <PayModal userId={session.id} onClose={() => setIsPayOpen(false)} onAdded={(m) => setSession((s) => (s ? { ...s, balance_minutes: s.balance_minutes + m } : s))} />}
|
<p className="text-center text-[11px] text-zinc-600">
|
||||||
|
${price}/hr · {freeMachines} machine free · {maxMachines} max · Bitcoin via BTCPay · clean residential proxies on sessions
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 <span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase ${cls}`}>{state}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-5 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 mb-1">{icon}<span className="font-bold">{name}</span></div>
|
||||||
|
<div className="text-[11px] text-zinc-500 mb-2">{tag}</div>
|
||||||
|
<p className="text-xs text-zinc-400 mb-4 flex-1">{desc}</p>
|
||||||
|
<button onClick={onClick} className={`w-full py-3 bg-gradient-to-r ${grad} text-black font-bold rounded-xl text-sm`}>{cta}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopUpButton({ token, onAdded }: { token: string; onAdded: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setOpen(true)} className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-500/20 border border-amber-500/40 text-amber-300 rounded-lg text-xs font-bold hover:bg-amber-500/30">
|
||||||
|
<Bitcoin className="w-3.5 h-3.5" /> Top Up
|
||||||
|
</button>
|
||||||
|
{open && <PayModal token={token} onClose={() => setOpen(false)} onAdded={onAdded} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PayModal({ token, onClose, onAdded }: { token: string; onClose: () => void; onAdded: () => void }) {
|
||||||
const [hours, setHours] = useState(5);
|
const [hours, setHours] = useState(5);
|
||||||
const [invoice, setInvoice] = useState<any>(null);
|
const [invoice, setInvoice] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -308,24 +292,20 @@ function PayModal({ userId, onClose, onAdded }: { userId: string; onClose: () =>
|
|||||||
const gen = async () => {
|
const gen = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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();
|
const d = await r.json();
|
||||||
if (r.ok) setInvoice(d);
|
if (r.ok) setInvoice(d);
|
||||||
} catch (e) { console.error(e); } finally { setLoading(false); }
|
} catch (e) { console.error(e); } finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// poll settlement
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!invoice) return;
|
if (!invoice) return;
|
||||||
const t = setInterval(async () => {
|
const t = setInterval(async () => {
|
||||||
const r = await fetch(`/api/me?userId=${userId}`);
|
const r = await fetch('/api/me', { headers: { Authorization: `Bearer ${token}` } });
|
||||||
const d = await r.json();
|
if (r.ok) { const d = await r.json(); if (d.user.balance_minutes > 0) { onAdded(); onClose(); } }
|
||||||
if (d.user.balance_minutes >= 0 && invoice.status === 'pending') {
|
|
||||||
// balance changed means settled — just close on next refresh
|
|
||||||
}
|
|
||||||
}, 5000);
|
}, 5000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [invoice, userId]);
|
}, [invoice, token]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur p-4">
|
||||||
@@ -363,3 +343,63 @@ function PayModal({ userId, onClose, onAdded }: { userId: string; onClose: () =>
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-[#05070d] flex items-center justify-center p-6 font-sans">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center gap-2 text-3xl font-black tracking-tight text-white">
|
||||||
|
<span className="text-cyan-400">VORTEX</span>GPU
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-400 mt-2">Rent a real GPU PC by the hour. Ubuntu sessions, Windows RDP, Linux SSH.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<button onClick={() => { setMode('login'); setError(''); }} className={`flex-1 py-2 rounded-xl border text-sm font-bold ${mode === 'login' ? 'border-cyan-500 bg-cyan-500/10 text-cyan-300' : 'border-zinc-800 text-zinc-500'}`}>
|
||||||
|
<LogIn className="w-4 h-4 inline mr-1" /> Login
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setMode('register'); setError(''); }} className={`flex-1 py-2 rounded-xl border text-sm font-bold ${mode === 'register' ? 'border-emerald-500 bg-emerald-500/10 text-emerald-300' : 'border-zinc-800 text-zinc-500'}`}>
|
||||||
|
<UserPlus className="w-4 h-4 inline mr-1" /> Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="bg-zinc-900/50 border border-zinc-800 rounded-2xl p-6 space-y-4 backdrop-blur">
|
||||||
|
<label className="block text-xs text-zinc-400">USERNAME:</label>
|
||||||
|
<input type="text" value={username} onChange={(e) => 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" />
|
||||||
|
<label className="block text-xs text-zinc-400">PASSWORD:</label>
|
||||||
|
<input type="password" value={password} onChange={(e) => 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 && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
<button disabled={loading} className="w-full py-3 bg-gradient-to-r from-cyan-500 to-blue-600 text-black font-bold rounded-xl text-sm disabled:opacity-50">
|
||||||
|
{loading ? 'Please wait...' : mode === 'login' ? 'Sign In' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-[11px] text-zinc-600 text-center mt-4">$1.00 / hour · 1st machine free · up to 3 machines · Bitcoin via BTCPay</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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)
|
Target: Ubuntu (nightmare .128, RTX 4080 SUPER 16GB)
|
||||||
|
|
||||||
Spawns in-browser Ubuntu desktop sessions, each with the 4080 attached (--gpus all):
|
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
|
- provision_ubuntu : docker run a full Ubuntu LXDE desktop (noVNC) with GPU, on a
|
||||||
dedicated port. Tenant gets a clean private machine; the physical
|
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.
|
- destroy_ubuntu : docker rm -f the session container.
|
||||||
- shell / hashcat / comfyui : run an arbitrary command against the local GPU.
|
- shell / hashcat / comfyui : run an arbitrary command against the local GPU.
|
||||||
|
|
||||||
@@ -110,18 +112,29 @@ def run_shell(command):
|
|||||||
return False, f"error: {e}"
|
return False, f"error: {e}"
|
||||||
|
|
||||||
|
|
||||||
def provision_ubuntu(instance_id, port, password, resolution):
|
def provision_ubuntu(instance_id, port, password, resolution, proxy=None):
|
||||||
"""Spawn a full Ubuntu desktop session with the 4080 attached (noVNC on mapped port)."""
|
"""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}"
|
name = f"vortex-{instance_id}"
|
||||||
_docker(["rm", "-f", name], timeout=30) # clean any stale instance
|
_docker(["rm", "-f", name], timeout=30) # clean any stale instance
|
||||||
r = _docker(["run", "-d", "--gpus", "all", "--name", name,
|
args = ["run", "-d", "--gpus", "all", "--name", name,
|
||||||
"-p", f"{port}:80",
|
"-p", f"{port}:80",
|
||||||
"-e", f"VNC_PASSWORD={password}",
|
"-e", f"VNC_PASSWORD={password}",
|
||||||
"-e", f"RESOLUTION={resolution or '1440x900'}",
|
"-e", f"RESOLUTION={resolution or '1440x900'}"]
|
||||||
SESSION_IMAGE])
|
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:
|
if r.returncode == 0:
|
||||||
cid = r.stdout.strip()[:12]
|
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]}"
|
return False, f"failed: {r.stderr.strip()[:400]}"
|
||||||
|
|
||||||
|
|
||||||
@@ -141,7 +154,8 @@ def handle_job(job):
|
|||||||
ok, result = provision_ubuntu(payload.get("instanceId", "inst"),
|
ok, result = provision_ubuntu(payload.get("instanceId", "inst"),
|
||||||
int(payload.get("port", 6090)),
|
int(payload.get("port", 6090)),
|
||||||
payload.get("password", "vortex"),
|
payload.get("password", "vortex"),
|
||||||
payload.get("resolution", "1440x900"))
|
payload.get("resolution", "1440x900"),
|
||||||
|
payload.get("proxy"))
|
||||||
elif kind == "destroy_ubuntu":
|
elif kind == "destroy_ubuntu":
|
||||||
ok, result = destroy_ubuntu(payload.get("instanceId", ""))
|
ok, result = destroy_ubuntu(payload.get("instanceId", ""))
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user