'use strict'; /* * AeroMC – CurseForge-Weiterleitung * --------------------------------- * Hält den CurseForge-API-Schlüssel und stellt die Anfragen stellvertretend * für den Launcher. So bleibt der Schlüssel auf dem Server: in der verteilten * .exe wäre er für jeden auslesbar, und CurseForge untersagt die Weitergabe * an Dritte ausdrücklich. * * Wege: * GET /health – Lebenszeichen, ohne Schlüsselangaben * POST /v1/mods/files – Dateiliste auflösen (Rumpf: { fileIds: [...] }) * GET /v1/download?url=... – Mod-Datei durchreichen (CDN verlangt seit * Juli 2026 ebenfalls einen Schlüssel) * * Einstellungen über Umgebungsvariablen, siehe .env.example. */ const http = require('http'); const { Readable } = require('stream'); const KEY = (process.env.CF_API_KEY || '').trim(); const PORT = Number(process.env.PORT || 8420); const HOST = process.env.HOST || '0.0.0.0'; /* * Ein oder mehrere gültige Token, durch Komma getrennt. * * Mehrere deshalb, weil das Token in ausgelieferten Paketen steckt: Beim * Wechsel trägst du eine Zeit lang altes und neues ein, damit ältere * Installationen weiterlaufen, bis alle das Update haben. Danach das alte * streichen. */ const TOKENS = new Set( String(process.env.CF_PROXY_TOKEN || '') .split(',').map((t) => t.trim()).filter(Boolean), ); const LIMIT_PRO_MIN = Number(process.env.RATE_LIMIT_PER_MIN || 60); const MAX_MB = Number(process.env.MAX_DOWNLOAD_MB || 512); // Anzahl der Reverse-Proxies davor. 0 = keiner, 1 = z. B. Nginx Proxy Manager, // 2 = z. B. Cloudflare vor dem Proxy Manager. const PROXY_STUFEN = Math.max(0, Number(process.env.TRUST_PROXY || 0) || 0); const CF_API = 'https://api.curseforge.com/v1'; // Ohne User-Agent weisen Dienste hinter Cloudflare Anfragen gerne mit 403 ab. const UA = 'AeroMC-Launcher-Proxy/1.0 (+https://git.viper.ipv64.net)'; function cfKopfzeilen(weitere) { return Object.assign({ 'x-api-key': KEY, Accept: 'application/json', 'User-Agent': UA }, weitere || {}); } // Nur diese Hosts dürfen durchgereicht werden. Ohne diese Liste wäre der // Server ein offenes Tor in dein Netz – jemand könnte ihn Adressen im // Heimnetz abrufen lassen und die Antwort mitlesen. const ERLAUBTE_HOSTS = new Set([ 'edge.forgecdn.net', 'mediafilez.forgecdn.net', 'media.forgecdn.net', ]); if (!KEY) { console.error('CF_API_KEY fehlt. Bitte setzen (siehe .env.example) und neu starten.'); process.exit(1); } // --------------------------------------------------------------------------- // Ratenbegrenzung: einfaches Zeitfenster je Absender // --------------------------------------------------------------------------- const zaehler = new Map(); // ip -> { anzahl, bis } /* * Ermittelt den Absender – die Grundlage der Ratenbegrenzung. * * nginx & Co. hängen die echte Adresse hinten an X-Forwarded-For an, statt * den Kopfeintrag zu ersetzen. Alles davor kann der Aufrufer frei erfinden. * Vertrauenswürdig ist deshalb nur der Eintrag, den die letzte eigene * Proxy-Stufe geschrieben hat: von rechts gezählt. * * Aufrufer schickt "1.2.3.4", ein Proxy davor * -> "1.2.3.4, " -> mit TRUST_PROXY=1 zählt */ function absender(req) { if (PROXY_STUFEN > 0) { const kette = String(req.headers['x-forwarded-for'] || '') .split(',').map((s) => s.trim()).filter(Boolean); const i = kette.length - PROXY_STUFEN; if (i >= 0 && kette[i]) return kette[i]; // Kette kürzer als erwartet -> nicht dem Aufrufer glauben } return req.socket.remoteAddress || 'unbekannt'; } function darfNoch(ip) { if (LIMIT_PRO_MIN <= 0) return true; const jetzt = Date.now(); const e = zaehler.get(ip); if (!e || jetzt > e.bis) { zaehler.set(ip, { anzahl: 1, bis: jetzt + 60000 }); return true; } e.anzahl++; return e.anzahl <= LIMIT_PRO_MIN; } // alte Einträge regelmäßig wegräumen, sonst wächst die Liste unbegrenzt setInterval(() => { const jetzt = Date.now(); for (const [ip, e] of zaehler) if (jetzt > e.bis) zaehler.delete(ip); }, 60000).unref(); // --------------------------------------------------------------------------- // Hilfen // --------------------------------------------------------------------------- function sendeJson(res, code, daten) { const koerper = JSON.stringify(daten); res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(koerper) }); res.end(koerper); } function tokenOk(req) { if (!TOKENS.size) return true; return TOKENS.has(String(req.headers['x-aeromc-token'] || '').trim()); } function leseKoerper(req, maxBytes = 1_000_000) { return new Promise((resolve, reject) => { let daten = ''; let laenge = 0; req.on('data', (d) => { laenge += d.length; if (laenge > maxBytes) { reject(new Error('Anfrage zu groß')); req.destroy(); return; } daten += d; }); req.on('end', () => resolve(daten)); req.on('error', reject); }); } // prüft die Zieladresse einer Download-Anfrage function pruefeDownloadZiel(roh) { let u; try { u = new URL(roh); } catch { return null; } if (u.protocol !== 'https:') return null; if (!ERLAUBTE_HOSTS.has(u.hostname)) return null; return u; } // --------------------------------------------------------------------------- // Wege // --------------------------------------------------------------------------- async function dateilisteAufloesen(req, res) { let rumpf; try { rumpf = JSON.parse(await leseKoerper(req)); } catch { return sendeJson(res, 400, { error: 'Ungültiger Rumpf.' }); } const ids = Array.isArray(rumpf && rumpf.fileIds) ? rumpf.fileIds.map(Number).filter(Boolean) : []; if (!ids.length) return sendeJson(res, 400, { error: 'fileIds fehlen.' }); if (ids.length > 100) return sendeJson(res, 400, { error: 'Höchstens 100 fileIds je Anfrage.' }); const antwort = await fetch(CF_API + '/mods/files', { method: 'POST', headers: cfKopfzeilen({ 'Content-Type': 'application/json' }), body: JSON.stringify({ fileIds: ids }), }); if (!antwort.ok) { // Schlüsselprobleme nicht im Wortlaut weiterreichen const code = antwort.status === 401 || antwort.status === 403 ? 502 : antwort.status; return sendeJson(res, code, { error: 'CurseForge antwortet mit HTTP ' + antwort.status }); } sendeJson(res, 200, await antwort.json()); } async function dateiDurchreichen(req, res, url) { const ziel = pruefeDownloadZiel(url.searchParams.get('url') || ''); if (!ziel) return sendeJson(res, 400, { error: 'Adresse fehlt oder zeigt nicht auf das CurseForge-CDN.' }); // Weiterleitungen selbst verfolgen und jedes Mal den Host neu prüfen let aktuell = ziel; let antwort = null; for (let sprung = 0; sprung < 4; sprung++) { antwort = await fetch(aktuell, { headers: cfKopfzeilen(), redirect: 'manual' }); const weiter = antwort.headers.get('location'); if (antwort.status >= 300 && antwort.status < 400 && weiter) { const naechste = pruefeDownloadZiel(new URL(weiter, aktuell).toString()); if (!naechste) return sendeJson(res, 502, { error: 'Weiterleitung führt aus dem CurseForge-CDN heraus.' }); aktuell = naechste; continue; } break; } if (!antwort || !antwort.ok) { return sendeJson(res, antwort ? antwort.status : 502, { error: 'Download fehlgeschlagen.' }); } const laenge = Number(antwort.headers.get('content-length')) || 0; if (MAX_MB > 0 && laenge > MAX_MB * 1024 * 1024) { return sendeJson(res, 413, { error: `Datei größer als erlaubt (${MAX_MB} MB).` }); } res.writeHead(200, { 'Content-Type': antwort.headers.get('content-type') || 'application/octet-stream', ...(laenge ? { 'Content-Length': String(laenge) } : {}), }); Readable.fromWeb(antwort.body).pipe(res); } // --------------------------------------------------------------------------- const server = http.createServer(async (req, res) => { let url; try { url = new URL(req.url, 'http://localhost'); } catch { return sendeJson(res, 400, { error: 'Ungültige Anfrage.' }); } if (url.pathname === '/health') { return sendeJson(res, 200, { ok: true, dienst: 'aeromc-cf-proxy' }); } if (!tokenOk(req)) return sendeJson(res, 401, { error: 'Nicht berechtigt.' }); /* * Hilfe beim Einstellen von TRUST_PROXY: zeigt, was der Dienst von DIESER * Anfrage sieht. Steht bewusst hinter der Token-Prüfung, damit Scanner * nichts über den Aufbau erfahren. Aufruf von außen: * * curl -s -H 'x-aeromc-token: DEIN-TOKEN' https://deine-adresse/whoami * * erkannt == deine echte öffentliche IP -> TRUST_PROXY stimmt * erkannt == eine interne Adresse -> zu niedrig * erkannt == eine erfundene Adresse -> zu hoch * * Vor der Ratenbegrenzung, damit die Diagnose auch dann noch antwortet, * wenn man sich gerade selbst ausgesperrt hat. */ if (url.pathname === '/whoami') { const kette = String(req.headers['x-forwarded-for'] || '') .split(',').map((s) => s.trim()).filter(Boolean); return sendeJson(res, 200, { erkannt: absender(req), proxyStufen: PROXY_STUFEN, kette, verbindungVon: req.socket.remoteAddress || null, hinweis: 'Stimmt "erkannt" mit deiner echten öffentlichen IP überein, ist TRUST_PROXY richtig gesetzt.', }); } /* * Selbsttest: prüft den API-Schlüssel, den DIESER Prozess wirklich geladen * hat, direkt gegen CurseForge. Beantwortet die zwei Fragen, die man von * außen sonst nicht auseinanderhalten kann: * - hat der Prozess überhaupt den neuen Schlüssel? * - akzeptiert CurseForge ihn? * Gibt nur Länge und die ersten Zeichen aus (bei CurseForge immer "$2a$10$"), * niemals den Schlüssel selbst. */ if (url.pathname === '/selftest') { const roh = process.env.CF_API_KEY || ''; const befund = { schluessel: { laenge: KEY.length, beginntMit: KEY.slice(0, 7), randzeichenEntfernt: roh !== KEY, sahAusWieCurseForge: /^\$2[aby]\$\d\d\$/.test(KEY), }, curseforge: null, urteil: '', }; /* * Mehrere Zugänge nacheinander prüfen. Erst der Vergleich zeigt, ob der * Schlüssel grundsätzlich abgelehnt wird oder nur für bestimmte Bereiche * fehlt – ein einzelner Aufruf kann das nicht unterscheiden. */ const PROBEN = [ { name: 'games – Liste aller Spiele', method: 'GET', pfad: '/games' }, { name: 'games/432 – Minecraft', method: 'GET', pfad: '/games/432' }, { name: 'categories – Minecraft-Kategorien', method: 'GET', pfad: '/categories?gameId=432' }, { name: 'mods/search – Modsuche', method: 'GET', pfad: '/mods/search?gameId=432&searchFilter=jei' }, { name: 'mods/files – was der Import braucht', method: 'POST', pfad: '/mods/files', body: { fileIds: [4959679] } }, ]; befund.proben = []; for (const p of PROBEN) { try { const a = await fetch(CF_API + p.pfad, { method: p.method, headers: cfKopfzeilen(p.body ? { 'Content-Type': 'application/json' } : undefined), body: p.body ? JSON.stringify(p.body) : undefined, }); let text = ''; try { text = (await a.text()).replace(/\s+/g, ' ').trim().slice(0, 160); } catch { /* egal */ } befund.proben.push({ name: p.name, status: a.status, server: a.headers.get('server') || null, antwortAnfang: a.ok ? '(ok)' : text, }); } catch (err) { befund.proben.push({ name: p.name, fehler: err.message }); } } const stati = befund.proben.map((p) => p.status); const alleAbgelehnt = stati.every((s) => s === 401 || s === 403); const alleGut = stati.every((s) => s >= 200 && s < 300); const nurDateien = befund.proben.find((p) => p.name.startsWith('mods/files')); if (alleGut) befund.urteil = 'Alle Zugänge offen – der Schlüssel ist in Ordnung.'; else if (alleAbgelehnt) { befund.urteil = 'Kein einziger Zugang offen: Dieser Schlüssel gilt bei CurseForge nicht. ' + 'Entweder wurde er nach dem Kopieren erneut erzeugt („Regenerate" macht den alten sofort ungültig), ' + 'oder das Organisationskonto ist nicht freigeschaltet. Kein Fehler im Launcher oder auf diesem Server.'; } else if (nurDateien && (nurDateien.status === 401 || nurDateien.status === 403)) { befund.urteil = 'Der Schlüssel gilt grundsätzlich, aber nicht für die Dateiabfrage. ' + 'Das deutet auf einen Schlüssel, der nur für ein einzelnes Spiel gilt – für Minecraft-Modpacks ' + 'wird der Organisationsschlüssel gebraucht.'; } else befund.urteil = 'Gemischtes Bild – siehe die einzelnen Proben.'; // Kurzfassung für den schnellen Blick befund.kurz = befund.proben.map((p) => `${p.name}: ${p.status ?? p.fehler}`); return sendeJson(res, 200, befund); } if (!darfNoch(absender(req))) return sendeJson(res, 429, { error: 'Zu viele Anfragen, bitte kurz warten.' }); try { if (req.method === 'POST' && url.pathname === '/v1/mods/files') return await dateilisteAufloesen(req, res); if (req.method === 'GET' && url.pathname === '/v1/download') return await dateiDurchreichen(req, res, url); sendeJson(res, 404, { error: 'Unbekannter Weg.' }); } catch (err) { console.error('[Fehler]', err.message); if (!res.headersSent) sendeJson(res, 500, { error: 'Interner Fehler.' }); else res.destroy(); } }); server.listen(PORT, HOST, () => { console.log(`AeroMC CurseForge-Weiterleitung läuft auf ${HOST}:${PORT}`); console.log(` Ratenbegrenzung: ${LIMIT_PRO_MIN > 0 ? LIMIT_PRO_MIN + '/Minute je Absender' : 'aus'}`); // niemals die Token selbst ausgeben – Protokolle wandern schnell woanders hin console.log(` Token verlangt : ${TOKENS.size ? 'ja (' + TOKENS.size + ' gültige)' : 'nein'}`); console.log(` Proxy-Stufen : ${PROXY_STUFEN} (${PROXY_STUFEN ? 'Absender aus X-Forwarded-For' : 'Absender aus der Verbindung'})`); }); module.exports = { server, pruefeDownloadZiel, darfNoch, absender, ERLAUBTE_HOSTS };