'use strict'; /* * streamdeck.js – lokale Schnittstelle für das AeroMC-Plugin des Elgato Stream Deck * ----------------------------------------------------------------------------- * Ein kleiner HTTP-Server, der AUSSCHLIESSLICH auf 127.0.0.1 lauscht und nur auf * Wunsch läuft (Einstellung "streamdeckEnabled", standardmäßig aus). * * Absicherung gegen fremde Zugriffe: * - nur Loopback, kein Zugriff aus dem Netzwerk * - jede Anfrage braucht den Zufalls-Schlüssel im Kopf "Authorization: Bearer …"; * der wird bei jedem Start neu erzeugt und nur in eine Datei im * Benutzerordner geschrieben, die das Plugin liest (streamdeck.json) * - Anfragen mit "Origin"-Kopf (also aus einer Webseite im Browser) und mit * fremdem "Host"-Kopf (DNS-Rebinding) werden abgewiesen * * Die eigentlichen Aktionen kommen von main.js (siehe init) – dieses Modul kennt * weder Instanzen noch Aufnahmen und reicht nur weiter. */ const http = require('http'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const STANDARD_PORT = 48615; const PORT_VERSUCHE = 10; const MAX_BODY = 4096; let deps = null; let server = null; let schluessel = null; let verbindungsDatei = null; /* * deps: { * userDataDir, * zustand() -> { instanzen: [{ id, name, laeuft, seit }], aufnahme, clip } * starte(id, server) -> { ok, message? } (server optional: direkt verbinden) * neustart(id) -> { ok, message? } * fenster() -> { ok } Launcher-Fenster zeigen bzw. wegminimieren * clipModus() -> { ok, message? } * ordner(id) -> { ok } Aufnahmen-Ordner öffnen * serverListe(id) -> { ok, server: [{ name, ip }] } * ping(ip) -> { ok, online, max, latencyMs } * screenshot() -> { ok, message? } Bild des Spielfensters in den Screenshot-Ordner * symbol(id) -> { ok, bild, pixel } Instanz-Bild als data:-URI * kopf(uuid) -> { ok, bild } Spielerkopf eines Kontos als data:-URI * absturz(id) -> { ok, bericht, zeit, frisch } neuester Absturzbericht der Instanz * konto(id) -> { ok } aktives Konto wechseln * backup() -> { ok, message? } Sicherung aller Instanzen starten * oeffne(art, id) -> { ok, message? } Fenster öffnen: friends | logs | console | crash * beende(id) -> { ok, message? } * clipSpeichern() -> { ok, message? } * aufnahmeUmschalten() -> { ok, message? } * } */ function init(d) { deps = d; verbindungsDatei = path.join(d.userDataDir, 'streamdeck.json'); } function istSchluesselGleich(angabe) { if (!schluessel || typeof angabe !== 'string') return false; const a = Buffer.from(angabe); const b = Buffer.from(schluessel); return a.length === b.length && crypto.timingSafeEqual(a, b); } function antworte(res, status, daten) { const text = JSON.stringify(daten); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text), 'Cache-Control': 'no-store', }); res.end(text); } function leseBody(req) { return new Promise((resolve, reject) => { let groesse = 0; const teile = []; req.on('data', (c) => { groesse += c.length; if (groesse > MAX_BODY) { reject(new Error('zu groß')); req.destroy(); return; } teile.push(c); }); req.on('end', () => { if (!teile.length) { resolve({}); return; } try { resolve(JSON.parse(Buffer.concat(teile).toString('utf8')) || {}); } catch { reject(new Error('kein JSON')); } }); req.on('error', reject); }); } async function behandle(req, res, port) { // Anfragen aus Webseiten (Origin) und DNS-Rebinding (fremder Host) gar nicht erst bedienen const host = String(req.headers.host || '').toLowerCase(); if (req.headers.origin || (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`)) { return antworte(res, 403, { ok: false, message: 'Nicht erlaubt.' }); } const auth = String(req.headers.authorization || ''); if (!auth.startsWith('Bearer ') || !istSchluesselGleich(auth.slice(7))) { return antworte(res, 401, { ok: false, message: 'Schlüssel fehlt oder ist falsch.' }); } const url = new URL(req.url || '/', 'http://127.0.0.1'); const pfad = url.pathname; if (req.method === 'GET' && pfad === '/state') { return antworte(res, 200, Object.assign({ ok: true }, deps.zustand({ freunde: url.searchParams.get('freunde') === '1' }))); } if (req.method === 'GET' && pfad === '/servers') { return antworte(res, 200, await deps.serverListe(url.searchParams.get('id') || '')); } if (req.method === 'GET' && pfad === '/icon') { const konto = url.searchParams.get('konto'); return antworte(res, 200, konto ? await deps.kopf(konto) : await deps.symbol(url.searchParams.get('id') || '')); } if (req.method === 'GET' && pfad === '/crash') { return antworte(res, 200, await deps.absturz(url.searchParams.get('id') || '')); } if (req.method === 'GET' && pfad === '/ping') { return antworte(res, 200, await deps.ping(url.searchParams.get('ip') || '')); } if (req.method !== 'POST') return antworte(res, 405, { ok: false, message: 'Nur GET/POST.' }); let body; try { body = await leseBody(req); } catch (err) { return antworte(res, 400, { ok: false, message: err.message }); } const id = typeof body.id === 'string' ? body.id : ''; let erg; if (pfad === '/launch' && id) erg = await deps.starte(id, typeof body.server === 'string' ? body.server.trim().slice(0, 253) : ''); else if (pfad === '/restart' && id) erg = await deps.neustart(id); else if (pfad === '/window') erg = await deps.fenster(); else if (pfad === '/clipmode') erg = await deps.clipModus(); else if (pfad === '/folder') erg = await deps.ordner(id); else if (pfad === '/screenshot') erg = await deps.screenshot(); else if (pfad === '/account' && id) erg = await deps.konto(id); else if (pfad === '/backup') erg = await deps.backup(); else if (pfad === '/open' && typeof body.art === 'string') erg = await deps.oeffne(body.art, id); else if (pfad === '/stop' && id) erg = await deps.beende(id); else if (pfad === '/clip') erg = await deps.clipSpeichern(); else if (pfad === '/record') erg = await deps.aufnahmeUmschalten(); else return antworte(res, 404, { ok: false, message: 'Unbekannte Aktion.' }); return antworte(res, erg && erg.ok ? 200 : 409, erg || { ok: false }); } function schreibeVerbindung(port) { try { fs.writeFileSync(verbindungsDatei, JSON.stringify({ port, token: schluessel }), 'utf8'); } catch { /* ohne Datei findet das Plugin den Launcher nicht – mehr geht nicht */ } } function loescheVerbindung() { try { fs.rmSync(verbindungsDatei, { force: true }); } catch { /* egal */ } } function lausche(port, versuche) { const s = http.createServer((req, res) => { behandle(req, res, port).catch(() => { try { antworte(res, 500, { ok: false }); } catch { /* egal */ } }); }); s.on('error', (err) => { if (err.code === 'EADDRINUSE' && versuche > 1 && server === s) { lausche(port + 1, versuche - 1); return; } if (server === s) server = null; }); server = s; s.listen(port, '127.0.0.1', () => { if (server === s) schreibeVerbindung(port); }); } function start() { if (server || !deps) return; schluessel = crypto.randomBytes(32).toString('hex'); lausche(STANDARD_PORT, PORT_VERSUCHE); } function stop() { if (server) { try { server.close(); } catch { /* egal */ } server = null; } schluessel = null; if (verbindungsDatei) loescheVerbindung(); } function configure(settings) { if (settings && settings.streamdeckEnabled === true) start(); else stop(); } module.exports = { init, configure, shutdown: stop };