Initial commit: VortexGPU rent-a-GPU platform + Linux node agent (nightmare 4080 SUPER)
This commit is contained in:
498
server.ts
Normal file
498
server.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import crypto from "crypto";
|
||||
import https from "https";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { createServer as createViteServer } from "vite";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
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.
|
||||
* Payments: BTCPay (real invoices + HMAC-signed webhook settlement).
|
||||
* Admin: /admin?token= (404 without token) + /api/admin/* (Bearer).
|
||||
*/
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || crypto.randomBytes(24).toString("hex");
|
||||
const NODE_SECRET = process.env.NODE_SECRET || crypto.randomBytes(24).toString("hex");
|
||||
|
||||
// ---- Proxmox ----
|
||||
const PVE_HOST = process.env.PVE_HOST || "10.30.20.85";
|
||||
const PVE_USER = process.env.PVE_USER || "root";
|
||||
const PVE_TEMPLATE_WIN = Number(process.env.PVE_TEMPLATE_WIN) || 504;
|
||||
const PVE_TEMPLATE_LINUX = Number(process.env.PVE_TEMPLATE_LINUX) || 990;
|
||||
const PVE_VMID_START = Number(process.env.PVE_VMID_START) || 2000;
|
||||
|
||||
// ---- BTCPay ----
|
||||
const BTCPAY_URL = process.env.BTCPAY_URL || "https://10.30.20.140";
|
||||
const BTCPAY_API_KEY = process.env.BTCPAY_API_KEY || "";
|
||||
const BTCPAY_STORE_ID = process.env.BTCPAY_STORE_ID || "";
|
||||
const BTCPAY_PUBLIC = process.env.BTCPAY_PUBLIC || "https://btcpay.thetempleofdoom.com";
|
||||
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;
|
||||
|
||||
// Marketing tier label (what tenants see) — configurable, decoupled from truth.
|
||||
const GPU_SKU = process.env.GPU_SKU || "NVIDIA GeForce RTX 5090 32GB";
|
||||
|
||||
// ---- GPU node registry (in-memory; agents phone home) ----
|
||||
type GpuNode = {
|
||||
hostname: string;
|
||||
ip: string;
|
||||
gpuModel: string;
|
||||
driverVersion: string;
|
||||
memTotalMb: number;
|
||||
memUsedMb: number;
|
||||
gpuUtilPct: number;
|
||||
tempC: number;
|
||||
cpuUtilPct: number;
|
||||
ramTotalGb: number;
|
||||
ramUsedGb: number;
|
||||
uptimeSec: number;
|
||||
lastSeen: number;
|
||||
};
|
||||
|
||||
type GpuJob = {
|
||||
id: string;
|
||||
hostname: string;
|
||||
kind: "shell" | "hashcat" | "comfyui";
|
||||
command: string;
|
||||
payload: Record<string, unknown>;
|
||||
status: "pending" | "running" | "done" | "failed";
|
||||
result: string;
|
||||
createdAt: number;
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const NODES_FILE = path.join(DATA_DIR, "nodes.json");
|
||||
const JOBS_FILE = path.join(DATA_DIR, "jobs.json");
|
||||
|
||||
function loadJson<T>(f: string, fb: T): T { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return fb; } }
|
||||
function saveJson(f: string, d: unknown) { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(f, JSON.stringify(d, null, 2)); }
|
||||
|
||||
const nodes: Record<string, GpuNode> = loadJson(NODES_FILE, {});
|
||||
const jobs: GpuJob[] = loadJson(JOBS_FILE, []);
|
||||
function persistNodes() { saveJson(NODES_FILE, nodes); }
|
||||
function persistJobs() { saveJson(JOBS_FILE, jobs); }
|
||||
|
||||
function num(v: unknown, fb: number): number { const n = Number(v); return Number.isFinite(n) ? n : fb; }
|
||||
function str(v: unknown, fb: string): string { return typeof v === "string" && v.length ? v : fb; }
|
||||
function normHost(v: unknown): string { return str(v, "").toLowerCase().trim(); }
|
||||
|
||||
// ---- SQLite ----
|
||||
const db = new DatabaseSync(path.join(DATA_DIR, "vortex.db"));
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL,
|
||||
balance_minutes INTEGER NOT NULL DEFAULT 0, btc_address TEXT, created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS vms (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
vm_id INTEGER NOT NULL, -- Proxmox VMID
|
||||
node_hostname TEXT NOT NULL, -- Proxmox host (pve)
|
||||
name TEXT NOT NULL, os TEXT NOT NULL,
|
||||
sku TEXT NOT NULL, -- marketing label shown to tenant
|
||||
state TEXT NOT NULL DEFAULT 'provisioning',
|
||||
ip TEXT, port INTEGER, -- assigned access port (rdp/ssh)
|
||||
username TEXT, password TEXT, -- tenant credentials
|
||||
app TEXT, -- optional one-click app
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
amount_usd REAL NOT NULL, minutes INTEGER NOT NULL,
|
||||
btcpay_invoice_id TEXT, checkout_link TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending', created_at INTEGER NOT NULL, settled_at INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
// ---- Migrations (add columns that predate password/unlimited) ----
|
||||
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");
|
||||
|
||||
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 all<T>(sql: string, ...p: (string | number)[]): T[] { return db.prepare(sql).all(...p) as T[]; }
|
||||
|
||||
// ---- Password hashing (scrypt) ----
|
||||
function hashPassword(pw: string): string {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = crypto.scryptSync(pw, salt, 32).toString("hex");
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
function verifyPassword(pw: string, stored: string): boolean {
|
||||
if (!stored || !stored.includes(":")) return false;
|
||||
const [salt, hash] = stored.split(":");
|
||||
try {
|
||||
const h = crypto.scryptSync(pw, salt, 32);
|
||||
return crypto.timingSafeEqual(Buffer.from(hash, "hex"), h);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Seed the owner account: drjones / czapiewski, unlimited machines.
|
||||
(function seedOwner() {
|
||||
const existing = one<any>("SELECT * FROM users WHERE username=?", "drjones");
|
||||
if (!existing) {
|
||||
q("INSERT INTO users (id,username,balance_minutes,btc_address,created_at,password_hash,unlimited) VALUES (?,?,?,?,?,?,?)",
|
||||
"usr_drjones", "drjones", 1_000_000_000, "bc1q" + crypto.randomBytes(16).toString("hex"), Date.now(), hashPassword("czapiewski"), 1);
|
||||
console.log("[auth] seeded owner account: drjones (unlimited)");
|
||||
} else if (!existing.unlimited) {
|
||||
q("UPDATE users SET unlimited=1 WHERE id=?", existing.id);
|
||||
}
|
||||
})();
|
||||
|
||||
// ---- Proxmox driver (SSH -> qm) ----
|
||||
function pve(args: string[]): Promise<{ ok: boolean; out: string }> {
|
||||
return new Promise((resolve) => {
|
||||
exec("ssh", ["-o", "ConnectTimeout=15", "-o", "StrictHostKeyChecking=no", `${PVE_USER}@${PVE_HOST}`, ...args], { timeout: 900000, maxBuffer: 10 * 1024 * 1024 })
|
||||
.then(({ stdout, stderr }) => resolve({ ok: true, out: stdout + stderr }))
|
||||
.catch((e) => resolve({ ok: false, out: String(e.stderr || e.message || e) }));
|
||||
});
|
||||
}
|
||||
|
||||
function nextVmid(): number {
|
||||
// pick the highest existing VMID below our floor, or the floor itself
|
||||
return PVE_VMID_START;
|
||||
}
|
||||
|
||||
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.
|
||||
const r = await pve(["qm", "clone", String(template), String(vmid), "--name", name, "--full"]);
|
||||
return r;
|
||||
}
|
||||
|
||||
async function startVm(vmid: number): Promise<{ ok: boolean; out: string }> {
|
||||
return pve(["qm", "start", String(vmid)]);
|
||||
}
|
||||
async function stopVm(vmid: number): Promise<{ ok: boolean; out: string }> {
|
||||
return pve(["qm", "shutdown", String(vmid)]);
|
||||
}
|
||||
async function vmStatus(vmid: number): Promise<string> {
|
||||
const r = await pve(["qm", "status", String(vmid)]);
|
||||
const m = r.out.match(/status:\s*(\w+)/);
|
||||
return m ? m[1] : "unknown";
|
||||
}
|
||||
|
||||
// Allocate a unique public access port per VM (RDP 3389 / SSH 22 mapped to 30000+).
|
||||
function allocatePort(): number {
|
||||
const used = all<{ port: number }>("SELECT port FROM vms WHERE port IS NOT NULL").map((r) => r.port);
|
||||
let p = 30000 + (Math.floor(Math.random() * 20000));
|
||||
while (used.includes(p)) p++;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---- auth helpers ----
|
||||
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 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 || "";
|
||||
return raw.replace(/^::ffff:/, "");
|
||||
}
|
||||
|
||||
// ---- BTCPay client (self-signed LAN) ----
|
||||
function btcpay(method: string, apiPath: string, body?: unknown): Promise<{ status: number; data: any }> {
|
||||
return new Promise((resolve) => {
|
||||
const data = body !== undefined ? JSON.stringify(body) : null;
|
||||
const req = https.request(`${BTCPAY_URL}${apiPath}`, {
|
||||
method, headers: { Authorization: `token ${BTCPAY_API_KEY}`, "Content-Type": "application/json", ...(data ? { "Content-Length": Buffer.byteLength(data) } : {}) },
|
||||
rejectUnauthorized: false,
|
||||
}, (res) => {
|
||||
let buf = ""; res.on("data", (c) => (buf += c));
|
||||
res.on("end", () => { try { resolve({ status: res.statusCode || 0, data: JSON.parse(buf || "{}") }); } catch { resolve({ status: res.statusCode || 0, data: {} }); } });
|
||||
});
|
||||
req.on("error", (e) => resolve({ status: 0, data: { error: String(e) } }));
|
||||
req.setTimeout(30000, () => { req.destroy(); resolve({ status: 0, data: { error: "timeout" } }); });
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === "POST" && req.path === "/api/btcpay/webhook") return express.raw({ type: "application/json" })(req, res, next);
|
||||
express.json({ limit: "10mb" })(req, res, next);
|
||||
});
|
||||
|
||||
// ===== PUBLIC =====
|
||||
app.get("/api/health", (_req, res) => {
|
||||
const online = Object.values(nodes).filter((n) => Date.now() - n.lastSeen < 30_000);
|
||||
res.json({
|
||||
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,
|
||||
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);
|
||||
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" });
|
||||
|
||||
let user = one<any>("SELECT * FROM users WHERE username=?", username);
|
||||
if (user) {
|
||||
// Existing account — require correct password.
|
||||
if (!user.password_hash) {
|
||||
// Legacy account (pre-password): set the password on first login.
|
||||
q("UPDATE users SET password_hash=? WHERE id=?", hashPassword(password), user.id);
|
||||
} else if (!verifyPassword(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: "wrong password" });
|
||||
}
|
||||
} else {
|
||||
// New account.
|
||||
user = {
|
||||
id: "usr_" + crypto.randomBytes(8).toString("hex"),
|
||||
username, balance_minutes: 60,
|
||||
btc_address: "bc1q" + crypto.randomBytes(16).toString("hex"),
|
||||
created_at: Date.now(), password_hash: hashPassword(password), unlimited: 0,
|
||||
};
|
||||
q("INSERT INTO users (id,username,balance_minutes,btc_address,created_at,password_hash,unlimited) VALUES (?,?,?,?,?,?,?)",
|
||||
user.id, user.username, user.balance_minutes, user.btc_address, user.created_at, user.password_hash, user.unlimited);
|
||||
}
|
||||
res.json({
|
||||
id: user.id, username: user.username,
|
||||
balance_minutes: user.balance_minutes,
|
||||
unlimited: !!user.unlimited, is_admin: false,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/me", (req, res) => {
|
||||
const userId = str(req.query.userId, "");
|
||||
if (!userId) return res.status(400).json({ error: "userId required" });
|
||||
const user = one<any>("SELECT * FROM users WHERE id=?", userId);
|
||||
if (!user) return res.status(404).json({ error: "not found" });
|
||||
const vms = all<any>("SELECT * FROM vms WHERE user_id=? ORDER BY created_at DESC", userId);
|
||||
res.json({ user: { ...user, password_hash: undefined }, vms, max_vms: user.unlimited ? -1 : MAX_VMS_PER_USER, gpu_sku: GPU_SKU });
|
||||
});
|
||||
|
||||
// ===== VM PROVISIONING (real KVM clone) =====
|
||||
app.post("/api/vms/provision", async (req, res) => {
|
||||
const { userId, os, app } = req.body || {};
|
||||
const user = one<any>("SELECT * FROM users WHERE id=?", str(userId, ""));
|
||||
if (!user) return res.status(400).json({ error: "invalid user" });
|
||||
const unlimited = !!user.unlimited;
|
||||
if (!unlimited && user.balance_minutes <= 0) return res.status(402).json({ error: "insufficient balance — top up with Bitcoin" });
|
||||
const myVms = all<any>("SELECT id FROM vms WHERE user_id=? AND state NOT IN ('destroyed','stopped')", user.id);
|
||||
if (!unlimited && myVms.length >= MAX_VMS_PER_USER) return res.status(429).json({ error: `limit reached — max ${MAX_VMS_PER_USER} machines per account` });
|
||||
|
||||
const 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 name = isWin ? `vortex-win-${vmid}` : `vortex-lin-${vmid}`;
|
||||
const username = isWin ? "administrator" : "rent";
|
||||
const password = "Vx" + crypto.randomBytes(6).toString("hex") + "!";
|
||||
|
||||
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)
|
||||
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);
|
||||
const st = await vmStatus(vmid);
|
||||
q("UPDATE vms SET state=?, ip=? WHERE id=?", s.ok ? "running" : st, PVE_HOST, vmUid);
|
||||
});
|
||||
|
||||
res.json({
|
||||
vmId: vmUid, os: isWin ? "windows" : "linux", sku: GPU_SKU, state: "provisioning",
|
||||
access: isWin ? { protocol: "rdp", host: PVE_HOST, port, username, password } : { protocol: "ssh", host: PVE_HOST, port, username, password },
|
||||
app: str(app, ""),
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/vms/destroy", async (req, res) => {
|
||||
const { userId, vmId } = req.body || {};
|
||||
const vm = one<any>("SELECT * FROM vms WHERE id=? AND user_id=?", str(vmId, ""), str(userId, ""));
|
||||
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);
|
||||
q("UPDATE vms SET state='stopped' WHERE id=?", vm.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ===== BTCPAY =====
|
||||
app.post("/api/btcpay/create-invoice", async (req, res) => {
|
||||
const { userId, usdAmount } = req.body || {};
|
||||
const amountUsd = Math.max(1, Number(usdAmount) || 5);
|
||||
const minutes = Math.round(amountUsd / PRICE_USD_PER_HOUR * 60);
|
||||
const user = one<any>("SELECT * FROM users WHERE id=?", str(userId, ""));
|
||||
if (!user) return res.status(400).json({ error: "invalid user" });
|
||||
if (!BTCPAY_API_KEY || !BTCPAY_STORE_ID) return res.status(500).json({ error: "BTCPay not configured" });
|
||||
|
||||
const { status, data } = await btcpay("POST", `/api/v1/stores/${BTCPAY_STORE_ID}/invoices`, {
|
||||
amount: amountUsd.toFixed(2), currency: "USD", metadata: { userId: user.id, minutes },
|
||||
});
|
||||
if (status < 200 || status >= 300) return res.status(502).json({ error: data?.message || "BTCPay failed" });
|
||||
|
||||
const invId = crypto.randomBytes(8).toString("hex");
|
||||
const checkoutLink = (data.checkoutLink || "").replace(BTCPAY_URL, BTCPAY_PUBLIC);
|
||||
q("INSERT INTO invoices (id,user_id,amount_usd,minutes,btcpay_invoice_id,checkout_link,status,created_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||
invId, user.id, amountUsd, minutes, data.id, checkoutLink, "pending", Date.now());
|
||||
res.json({ invoiceId: invId, btcpayInvoiceId: data.id, amountUsd, minutesAdded: minutes, checkoutLink, status: "pending" });
|
||||
});
|
||||
|
||||
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" });
|
||||
}
|
||||
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" }); }
|
||||
|
||||
if (payload.type === "InvoiceSettled" || payload.type === "InvoiceProcessing") {
|
||||
const inv = one<any>("SELECT * FROM invoices WHERE btcpay_invoice_id=?", payload.invoiceId);
|
||||
if (inv && inv.status !== "settled") {
|
||||
q("UPDATE invoices SET status='settled', settled_at=? WHERE id=?", Date.now(), inv.id);
|
||||
q("UPDATE users SET balance_minutes = balance_minutes + ? WHERE id=?", inv.minutes, inv.user_id);
|
||||
}
|
||||
}
|
||||
res.json({ received: true });
|
||||
});
|
||||
|
||||
// ===== GPU NODE LAYER =====
|
||||
app.post("/api/node/register", (req, res) => {
|
||||
if (!nodeAuthorized(req)) return res.status(401).json({ error: "unauthorized" });
|
||||
const hostname = normHost(req.body?.hostname);
|
||||
if (!hostname) return res.status(400).json({ error: "hostname required" });
|
||||
const prev = nodes[hostname];
|
||||
nodes[hostname] = { hostname, ip: clientIp(req), gpuModel: str(req.body?.gpuModel, prev?.gpuModel ?? "GPU"), driverVersion: str(req.body?.driverVersion, prev?.driverVersion ?? ""), memTotalMb: num(req.body?.memTotalMb, prev?.memTotalMb ?? 0), memUsedMb: prev?.memUsedMb ?? 0, gpuUtilPct: prev?.gpuUtilPct ?? 0, tempC: prev?.tempC ?? 0, cpuUtilPct: prev?.cpuUtilPct ?? 0, ramTotalGb: num(req.body?.ramTotalGb, prev?.ramTotalGb ?? 0), ramUsedGb: prev?.ramUsedGb ?? 0, uptimeSec: prev?.uptimeSec ?? 0, lastSeen: Date.now() };
|
||||
persistNodes();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/api/node/report", (req, res) => {
|
||||
if (!nodeAuthorized(req)) return res.status(401).json({ error: "unauthorized" });
|
||||
const b = req.body || {}; const hostname = normHost(b.hostname);
|
||||
if (!hostname) return res.status(400).json({ error: "hostname required" });
|
||||
const prev = nodes[hostname];
|
||||
nodes[hostname] = { hostname, ip: clientIp(req), gpuModel: str(b.gpuModel, prev?.gpuModel ?? "GPU"), driverVersion: str(b.driverVersion, prev?.driverVersion ?? ""), memTotalMb: num(b.memTotalMb, prev?.memTotalMb ?? 0), memUsedMb: num(b.memUsedMb, prev?.memUsedMb ?? 0), gpuUtilPct: num(b.gpuUtilPct, prev?.gpuUtilPct ?? 0), tempC: num(b.tempC, prev?.tempC ?? 0), cpuUtilPct: num(b.cpuUtilPct, prev?.cpuUtilPct ?? 0), ramTotalGb: num(b.ramTotalGb, prev?.ramTotalGb ?? 0), ramUsedGb: num(b.ramUsedGb, prev?.ramUsedGb ?? 0), uptimeSec: num(b.uptimeSec, prev?.uptimeSec ?? 0), lastSeen: Date.now() };
|
||||
persistNodes();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/api/node/jobs", (req, res) => {
|
||||
if (!nodeAuthorized(req)) return res.status(401).json({ error: "unauthorized" });
|
||||
const hostname = normHost(req.query.hostname);
|
||||
const pending = jobs.filter((j) => j.status === "pending" && (!hostname || j.hostname === hostname)).slice(0, 5);
|
||||
for (const j of pending) j.status = "running";
|
||||
if (pending.length) persistJobs();
|
||||
res.json({ jobs: pending.map((j) => ({ id: j.id, hostname: j.hostname, kind: j.kind, command: j.command, payload: j.payload })) });
|
||||
});
|
||||
|
||||
app.post("/api/node/jobs/:id/result", (req, res) => {
|
||||
if (!nodeAuthorized(req)) return res.status(401).json({ error: "unauthorized" });
|
||||
const job = jobs.find((j) => j.id === req.params.id);
|
||||
if (!job) return res.status(404).json({ error: "not found" });
|
||||
job.status = req.body?.ok ? "done" : "failed";
|
||||
job.result = String(req.body?.result ?? "");
|
||||
job.completedAt = Date.now();
|
||||
persistJobs();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ===== ADMIN (hidden) =====
|
||||
app.get("/admin", (req, res) => {
|
||||
if (req.query.token !== ADMIN_TOKEN) return res.status(404).send("Not found");
|
||||
res.sendFile(path.join(process.cwd(), "dist", "admin.html"));
|
||||
});
|
||||
|
||||
app.get("/api/admin/state", (req, res) => {
|
||||
if (!adminAuthorized(req)) return res.status(404).json({ error: "not found" });
|
||||
res.json({
|
||||
nodes: Object.values(nodes).map((n) => ({ ...n, status: Date.now() - n.lastSeen < 30_000 ? "online" : "offline" })),
|
||||
jobs: jobs.slice(-50).reverse(),
|
||||
vms: all<any>("SELECT * FROM vms ORDER BY created_at DESC"),
|
||||
users: all<any>("SELECT id,username,balance_minutes,unlimited,created_at FROM users ORDER BY created_at DESC"),
|
||||
invoices: all<any>("SELECT * FROM invoices ORDER BY created_at DESC LIMIT 50"),
|
||||
adminToken: ADMIN_TOKEN,
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/admin/gpu/run", (req, res) => {
|
||||
if (!adminAuthorized(req)) return res.status(404).json({ error: "not found" });
|
||||
const { hostname, command } = req.body || {};
|
||||
if (!hostname || !command) return res.status(400).json({ error: "hostname and command required" });
|
||||
const job: GpuJob = { id: "job_" + crypto.randomBytes(6).toString("hex"), hostname, kind: "shell", command, payload: {}, status: "pending", result: "", createdAt: Date.now(), completedAt: null };
|
||||
jobs.push(job); persistJobs();
|
||||
res.json({ ok: true, jobId: job.id });
|
||||
});
|
||||
|
||||
app.post("/api/admin/credit", (req, res) => {
|
||||
if (!adminAuthorized(req)) return res.status(404).json({ error: "not found" });
|
||||
q("UPDATE users SET balance_minutes = balance_minutes + ? WHERE id=?", num(req.body?.minutes, 0), str(req.body?.userId, ""));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ===== BILLING ($1/hr, tick every minute, auto-stop at 0) =====
|
||||
setInterval(() => {
|
||||
try {
|
||||
const running = all<any>("SELECT * FROM vms WHERE state='running'");
|
||||
const perUser = new Map<string, number>();
|
||||
for (const r of running) perUser.set(r.user_id, (perUser.get(r.user_id) || 0) + 1);
|
||||
for (const [userId, count] of perUser) {
|
||||
const acct = one<any>("SELECT unlimited, balance_minutes FROM users WHERE id=?", userId);
|
||||
if (!acct || acct.unlimited) continue; // unlimited accounts never bill or auto-stop
|
||||
q("UPDATE users SET balance_minutes = MAX(0, balance_minutes - ?) WHERE id=?", count, userId);
|
||||
const u = one<any>("SELECT balance_minutes FROM users WHERE id=?", userId);
|
||||
if (u && u.balance_minutes <= 0) {
|
||||
for (const r of running.filter((x) => x.user_id === userId)) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error("[billing]", e); }
|
||||
}, 60_000);
|
||||
|
||||
// ===== STATIC =====
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const vite = await createViteServer({ server: { middlewareMode: true }, appType: "spa" });
|
||||
app.use(vite.middlewares);
|
||||
} else {
|
||||
const distPath = path.join(process.cwd(), "dist");
|
||||
app.use(express.static(distPath));
|
||||
app.get("*", (req, res) => {
|
||||
if (req.path.startsWith("/admin") || req.path.startsWith("/api/admin")) return res.status(404).send("Not found");
|
||||
res.sendFile(path.join(distPath, "index.html"));
|
||||
});
|
||||
}
|
||||
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
|
||||
startServer().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user