From 214f97b5809a58f18f2d1ad0e83fb97ae222477e Mon Sep 17 00:00:00 2001 From: drjones Date: Mon, 24 Aug 2026 21:33:40 -0700 Subject: [PATCH] Ubuntu GPU sessions: spawn/destroy full desktop with 4080 attached via noVNC proxy. Fixed branding (was 'My Google AI Studio App'). --- index.html | 8 +-- package-lock.json | 173 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + server.ts | 91 +++++++++++++++++++++++- src/App.tsx | 68 +++++++++++++++++- 5 files changed, 334 insertions(+), 7 deletions(-) diff --git a/index.html b/index.html index b2a846c..497cc46 100644 --- a/index.html +++ b/index.html @@ -3,10 +3,10 @@ - My Google AI Studio App - - - + VortexGPU — Rent a GPU PC by the Hour + + + diff --git a/package-lock.json b/package-lock.json index a8caba8..611d1cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@vitejs/plugin-react": "^5.0.4", "dotenv": "^17.2.3", "express": "^4.21.2", + "http-proxy-middleware": "^3.0.3", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.1", @@ -1554,6 +1555,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1814,6 +1824,18 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -2171,6 +2193,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -2284,6 +2312,18 @@ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "license": "MIT" }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -2317,6 +2357,26 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -2573,6 +2633,37 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.7.tgz", + "integrity": "sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==", + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^14.18.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -2613,6 +2704,45 @@ "node": ">= 0.10" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3006,6 +3136,31 @@ "node": ">= 0.6" } }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -3371,6 +3526,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -3657,6 +3818,18 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/package.json b/package.json index ff8af7e..53f08ee 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@vitejs/plugin-react": "^5.0.4", "dotenv": "^17.2.3", "express": "^4.21.2", + "http-proxy-middleware": "^3.0.3", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.1", diff --git a/server.ts b/server.ts index f1ffaf3..ab2ae41 100644 --- a/server.ts +++ b/server.ts @@ -7,6 +7,7 @@ import { execFile } from "child_process"; import { promisify } from "util"; import { createServer as createViteServer } from "vite"; import { DatabaseSync } from "node:sqlite"; +import { createProxyMiddleware } from "http-proxy-middleware"; const exec = promisify(execFile); @@ -68,7 +69,7 @@ type GpuNode = { type GpuJob = { id: string; hostname: string; - kind: "shell" | "hashcat" | "comfyui"; + kind: "shell" | "hashcat" | "comfyui" | "provision_ubuntu" | "destroy_ubuntu"; command: string; payload: Record; status: "pending" | "running" | "done" | "failed"; @@ -119,6 +120,13 @@ db.exec(` btcpay_invoice_id TEXT, checkout_link TEXT, status TEXT NOT NULL DEFAULT 'pending', created_at INTEGER NOT NULL, settled_at INTEGER ); + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, user_id TEXT NOT NULL, + instance_id TEXT NOT NULL UNIQUE, node_hostname TEXT NOT NULL, + node_ip TEXT NOT NULL, port INTEGER NOT NULL, password TEXT NOT NULL, + resolution TEXT, state TEXT NOT NULL DEFAULT 'provisioning', + created_at INTEGER NOT NULL + ); `); // ---- Migrations (add columns that predate password/unlimited) ---- @@ -202,6 +210,21 @@ function allocatePort(): number { return p; } +// Allocate a session (noVNC) port from a dedicated range, distinct from VM ports. +function allocateSessionPort(): number { + const used = all<{ port: number }>("SELECT port FROM sessions WHERE port IS NOT NULL").map((r) => r.port); + let p = 6090; + while (used.includes(p) && p < 6190) p++; + return p; +} + +// Enqueue a GPU job for a node to pick up on its next poll. +function dispatchJob(hostname: string, kind: GpuJob["kind"], command: string, payload: Record): GpuJob { + const job: GpuJob = { id: "job_" + crypto.randomBytes(6).toString("hex"), hostname, kind, command, payload, status: "pending", result: "", createdAt: Date.now(), completedAt: null }; + jobs.push(job); persistJobs(); + return job; +} + // ---- 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}`; } @@ -380,6 +403,46 @@ async function startServer() { res.json({ received: true }); }); + // ===== UBUNTU GPU SESSIONS (spawn in-browser desktop with the 4080 attached) ===== + app.post("/api/session/spawn", (req, res) => { + const { userId, resolution } = req.body || {}; + const user = one("SELECT * FROM users WHERE id=?", str(userId, "")); + if (!user) return res.status(400).json({ error: "invalid user" }); + + // Target the Linux GPU node (nightmare) that runs the Ubuntu-session agent. + const hostname = "nightmare"; + const node = nodes[hostname]; + if (!node || Date.now() - node.lastSeen > 30_000) { + return res.status(503).json({ error: "GPU node offline — try again shortly" }); + } + + const instanceId = "sess_" + crypto.randomBytes(4).toString("hex"); + const port = allocateSessionPort(); + const password = "Ub" + crypto.randomBytes(6).toString("hex") + "!"; + const reso = str(resolution, "1440x900"); + const id = "ses_" + crypto.randomBytes(8).toString("hex"); + + q("INSERT INTO sessions (id,user_id,instance_id,node_hostname,node_ip,port,password,resolution,state,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)", + id, user.id, instanceId, hostname, node.ip, port, password, reso, "provisioning", Date.now()); + dispatchJob(hostname, "provision_ubuntu", "", { instanceId, port, password, resolution: reso }); + + res.json({ id, instanceId, port, password, resolution: reso, state: "provisioning", url: `/session/${instanceId}/` }); + }); + + app.get("/api/sessions", (req, res) => { + const userId = str(req.query.userId, ""); + if (!userId) return res.status(400).json({ error: "userId required" }); + res.json(all("SELECT * FROM sessions WHERE user_id=? ORDER BY created_at DESC", userId)); + }); + + app.post("/api/session/destroy", (req, res) => { + const sess = one("SELECT * FROM sessions WHERE id=? AND user_id=?", str(req.body?.sessionId, ""), str(req.body?.userId, "")); + if (!sess) return res.status(404).json({ error: "not found" }); + q("UPDATE sessions SET state='stopping' WHERE id=?", sess.id); + dispatchJob(sess.node_hostname, "destroy_ubuntu", "", { instanceId: sess.instance_id }); + res.json({ ok: true }); + }); + // ===== GPU NODE LAYER ===== app.post("/api/node/register", (req, res) => { if (!nodeAuthorized(req)) return res.status(401).json({ error: "unauthorized" }); @@ -417,6 +480,13 @@ async function startServer() { job.status = req.body?.ok ? "done" : "failed"; job.result = String(req.body?.result ?? ""); job.completedAt = Date.now(); + // Reflect provisioning result onto the session row. + const p = job.payload as any; + if (job.kind === "provision_ubuntu" && p?.instanceId) { + q("UPDATE sessions SET state=? WHERE instance_id=?", req.body?.ok ? "running" : "failed", p.instanceId); + } else if (job.kind === "destroy_ubuntu" && p?.instanceId) { + q("UPDATE sessions SET state='stopped' WHERE instance_id=?", p.instanceId); + } persistJobs(); res.json({ ok: true }); }); @@ -475,6 +545,22 @@ async function startServer() { } catch (e) { console.error("[billing]", e); } }, 60_000); + // ===== SESSION noVNC PROXY (WebSocket-capable) ===== + const sessionProxy = createProxyMiddleware({ + target: "http://127.0.0.1:1", + changeOrigin: true, + ws: true, + pathFilter: "/session/**", + router: (req) => { + const m = (req.url || "").match(/^\/session\/([^/]+)/); + if (!m) return "http://127.0.0.1:1"; + const sess = one("SELECT * FROM sessions WHERE instance_id=?", m[1]); + return sess ? `http://${sess.node_ip}:${sess.port}` : "http://127.0.0.1:1"; + }, + pathRewrite: (path) => path.replace(/^\/session\/[^/]+/, "") || "/", + }); + app.use(sessionProxy); + // ===== STATIC ===== if (process.env.NODE_ENV !== "production") { const vite = await createViteServer({ server: { middlewareMode: true }, appType: "spa" }); @@ -488,11 +574,12 @@ async function startServer() { }); } - 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] Proxmox ${PVE_HOST} | win tpl ${PVE_TEMPLATE_WIN} | linux tpl ${PVE_TEMPLATE_LINUX}`); console.log(`[VortexGPU] GPU SKU: ${GPU_SKU}`); }); + server.on("upgrade", sessionProxy.upgrade); } startServer().catch((e) => { console.error(e); process.exit(1); }); diff --git a/src/App.tsx b/src/App.tsx index 590bb70..b2ecba2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,12 +24,24 @@ interface ApiVm { created_at: number; } +interface ApiSession { + id: string; + instance_id: string; + 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; } export default function App() { const [session, setSession] = useState(null); const [vms, setVms] = useState([]); - const [gpuSku, setGpuSku] = useState('NVIDIA GeForce RTX 5090 32GB'); + const [sessions, setSessions] = useState([]); + const [gpuSku, setGpuSku] = useState('NVIDIA GeForce RTX 4080 SUPER 16GB'); const [maxVms, setMaxVms] = useState(3); const [loginName, setLoginName] = useState(''); const [loginPass, setLoginPass] = useState(''); @@ -50,6 +62,8 @@ export default function App() { 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]); @@ -93,6 +107,22 @@ export default function App() { refresh(); }; + const handleSpawnSession = async () => { + if (!session) return; + setLoading(true); setError(''); + try { + const r = await fetch('/api/session/spawn', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: session.id, resolution: '1440x900' }) }); + const d = await r.json(); + if (r.ok) refresh(); + else setError(d.error || 'spawn failed'); + } catch { setError('network error'); } finally { setLoading(false); } + }; + + const handleDestroySession = async (sessionId: string) => { + await fetch('/api/session/destroy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: session!.id, sessionId }) }); + refresh(); + }; + if (!session) { return (
@@ -184,6 +214,42 @@ export default function App() {
+ {/* Ubuntu GPU Sessions */} +
+
+

Ubuntu GPU Sessions

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

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

+ + + {sessions.length > 0 && ( +
+ {sessions.map((s) => ( +
+
+
+ +
+
{s.instance_id}
+
{s.resolution} · node {s.node_hostname}:{s.port}
+
+
+ {s.state} +
+
+ Open Desktop + +
+
VNC password: {s.password}
+
+ ))} +
+ )} +
+ {/* Machines */} {vms.length === 0 ? (