// CTmine-client Status- und Steuerungs-API. // Absichtlich ohne externe Abhängigkeiten (nur Node-Built-ins), damit der // Container schlank bleibt und kein npm install im Runtime-Stage nötig ist. // // Endpunkte: // GET /api/status → { display, vnc, minecraft, resolution, timestamp } // POST /api/mc/start → startet Prism Launcher // POST /api/mc/stop → beendet Prism + Minecraft // // nginx proxyt /api zu diesem Server (127.0.0.1:3000 im Container). import { createServer } from 'node:http' import { exec, execFile, spawn } from 'node:child_process' import { promisify } from 'node:util' const execAsync = promisify(exec) const PORT = Number(process.env.MC_API_PORT || 3000) // --- Konfiguration aus Environment --------------------------------------- const CONFIG_DIR = process.env.CONFIG_DIR || '/config' const PRISM_DIR = process.env.PRISM_DIR || `${CONFIG_DIR}/prism` const DISPLAY = process.env.DISPLAY || ':0' const ACCOUNT = process.env.MC_ACCOUNT || '' // Profilname in Prism const MC_VERSION = process.env.MC_VERSION || '' // optional, z.B. "1.21" // --- Hilfsfunktionen ----------------------------------------------------- /** * Liefert true, wenn mindestens ein Prozess auf das Muster passt. * Wichtig: pgrep -f per Shell matched sonst auf die Shell selbst, deren * Kommandozeile das Pattern enthält. Wir filtern deshalb die Shell-basierten * Aufrufe (sh/bash -c …) heraus und prüfen nur „echte“ Treffer. */ async function pgrep(pattern) { try { // -a gibt die volle Kommandozeile aus, sodass wir filtern können. const { stdout } = await execAsync(`pgrep -fa '${pattern}' || true`) const lines = stdout.trim().split('\n').filter(Boolean) // Nur Treffer zählen, die nicht themselves die Such-Shell sind. const real = lines.filter((line) => !/\b(?:sh|bash) -c .*pgrep/.test(line)) return real.length > 0 } catch { return false } } /** Führt ein Kommando als der Runtime-Benutzer aus, falls PUID gesetzt ist. */ function asUser(cmd) { const uid = process.env.PUID const gid = process.env.PGID // Wenn wir root sind und PUID gesetzt ist → per runpuuids als Benutzer laufen. if (process.getuid && process.getuid() === 0 && uid && gid) { return `gosu ${uid}:${gid} bash -lc '${cmd.replace(/'/g, "'\\''")}'` } return cmd } /** Sicheres JSON-Antwort-Helfer. */ function sendJson(res, status, body) { const payload = JSON.stringify(body) res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), }) res.end(payload) } // --- Status -------------------------------------------------------------- async function readResolution() { try { const { stdout } = await execAsync('xdpyinfo -display ' + DISPLAY + ' 2>/dev/null | grep dimensions') const m = /(\d+)x(\d+)/.exec(stdout) if (m) return { width: Number(m[1]), height: Number(m[2]) } } catch { /* xdpyinfo nicht verfügbar */ } return undefined } async function getStatus() { const [display, vnc, minecraft, resolution] = await Promise.all([ pgrep('Xvfb ' + DISPLAY), pgrep('x11vnc'), // Prism portable Binary heißt 'prismlauncher' (klein); der offizielle Build // 'PrismLauncher'. Minecraft selbst läuft als java-Prozess mit net.minecraft. pgrep('prismlauncher|PrismLauncher|java.*net\\.minecraft'), readResolution(), ]) return { display, vnc, minecraft, resolution, timestamp: Date.now(), } } // --- Minecraft-Steuerung ------------------------------------------------- async function startMinecraft() { if (await pgrep('prismlauncher|PrismLauncher|java.*net\\.minecraft')) { return { ok: true, message: 'Minecraft läuft bereits.' } } // PrismLauncher im Headless-CLI-Modus gibt es nicht zuverlässig über alle // Versionen. Wir starten daher die GUI-Anwendung auf dem virtuellen Display; // der Benutzer interagiert dann per VNC. Das ist der robuste Weg. // // Die API läuft bereits als ctmine-Benutzer (via gosu in supervisor.conf), // daher starten wir Prism direkt ohne weiteren gosu-Wrapper. const child = spawn(PRISM_DIR + '/PrismLauncher', [], { cwd: PRISM_DIR, detached: true, stdio: 'ignore', env: { ...process.env, DISPLAY, HOME: CONFIG_DIR, }, }) child.on('error', () => { /* wird über Status-Polling sichtbar */ }) child.unref() return { ok: true, message: 'Prism Launcher wird gestartet. Im VNC-Fenster sichtbar.' } } async function stopMinecraft() { // Zuerst sauber Prism schließen, dann Restprozesse killen. try { await execAsync("pkill -INT -f 'prismlauncher|PrismLauncher' || true") await new Promise((r) => setTimeout(r, 1500)) await execAsync("pkill -TERM -f 'prismlauncher|PrismLauncher|net.minecraft' || true") } catch { /* pkill liefert !=0 wenn nichts läuft — egal */ } return { ok: true, message: 'Minecraft wurde beendet.' } } // --- HTTP-Server --------------------------------------------------------- const server = createServer(async (req, res) => { // CORS frei für lokales Netz (Dashboard läuft gleicher Origin, aber sicher). res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type') if (req.method === 'OPTIONS') { res.writeHead(204) return res.end() } const url = new URL(req.url, `http://localhost:${PORT}`) try { if (req.method === 'GET' && url.pathname === '/api/status') { return sendJson(res, 200, await getStatus()) } if (req.method === 'POST' && url.pathname === '/api/mc/start') { return sendJson(res, 200, await startMinecraft()) } if (req.method === 'POST' && url.pathname === '/api/mc/stop') { return sendJson(res, 200, await stopMinecraft()) } sendJson(res, 404, { ok: false, message: 'Not found' }) } catch (err) { const message = err instanceof Error ? err.message : String(err) sendJson(res, 500, { ok: false, message }) } }) server.listen(PORT, '127.0.0.1', () => { console.log(`[ctmine-api] lauscht auf 127.0.0.1:${PORT}`) })