'use strict'; const http = require('http'); const crypto = require('crypto'); const { Readable, Transform, pipeline } = 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'; 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); // gleichzeitige Downloads insgesamt – schützt die Bandbreite, falls jemand mit dem // (im Launcher steckenden) Token viele große Dateien parallel zieht. 0 = keine Grenze. const MAX_PARALLEL = Number(process.env.MAX_PARALLEL_DOWNLOADS ?? 8); // wie lange CurseForge für eine Antwort brauchen darf, bevor die Anfrage abgebrochen wird const UPSTREAM_TIMEOUT_MS = 30_000; let laufendeDownloads = 0; const SELFTEST_ABSTAND_MS = 30_000; let selftestErgebnis = null; let selftestZeit = 0; const PROXY_STUFEN = Math.max(0, Number(process.env.TRUST_PROXY || 0) || 0); const CF_API = 'https://api.curseforge.com/v1'; 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 || {}); } 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); } // Ohne Token wäre der Dienst für jeden offen, der die Adresse kennt – und damit // auch der API-Key und das Download-Kontingent dahinter. Deshalb startet er // ohne CF_PROXY_TOKEN gar nicht erst. Wer ihn bewusst offen betreiben will // (z. B. nur im eigenen Netz), muss das ausdrücklich sagen: CF_PROXY_OFFEN=1. const OFFEN_ERLAUBT = process.env.CF_PROXY_OFFEN === '1'; if (!TOKENS.size && !OFFEN_ERLAUBT) { console.error('CF_PROXY_TOKEN fehlt. Ohne Token könnte jeder den Dienst (und deinen API-Key) benutzen.'); console.error('Token setzen (siehe .env.example) – oder, nur für ein abgeschottetes Netz, CF_PROXY_OFFEN=1.'); 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); } // Vergleich in gleichbleibender Zeit, damit sich ein Token nicht Zeichen für // Zeichen über die Antwortdauer erraten lässt function gleich(a, b) { const x = crypto.createHash('sha256').update(a).digest(); const y = crypto.createHash('sha256').update(b).digest(); return crypto.timingSafeEqual(x, y); } function tokenOk(req) { if (!TOKENS.size) return OFFEN_ERLAUBT; // ohne Token nur mit ausdrücklicher Freigabe const erhalten = String(req.headers['x-aeromc-token'] || '').trim(); if (!erhalten) return false; let ok = false; for (const t of TOKENS) if (gleich(t, erhalten)) ok = true; return ok; } 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 }), signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); 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()); } /* * Katalog-Wege: Suche, Kategorien und Dateiliste einer Mod. Es wird nur durchgereicht, * was der Launcher dafür braucht – jeder Weg hat eine feste Liste erlaubter Parameter, * andere werden verworfen. gameId ist immer Minecraft (432), damit sich der Dienst nicht * für andere Spiele missbrauchen lässt. Antworten werden NICHT zwischengespeichert * (die CurseForge-Bedingungen verbieten es, Daten aus der API zu speichern). */ const MC_SPIEL = '432'; const ganz = (min, max) => (v) => /^\d{1,9}$/.test(v) && Number(v) >= min && Number(v) <= max; const text = (maxLaenge) => (v) => v.length > 0 && v.length <= maxLaenge && !/[\u0000-\u001f]/.test(v); const KATALOG_PARAMETER = { suche: { classId: ganz(0, 100000), categoryId: ganz(0, 100000), searchFilter: text(100), gameVersion: text(32), modLoaderType: ganz(0, 6), sortField: ganz(1, 8), sortOrder: (v) => v === 'asc' || v === 'desc', pageSize: ganz(1, 50), index: ganz(0, 10000), }, kategorien: { classId: ganz(0, 100000) }, dateien: { gameVersion: text(32), modLoaderType: ganz(0, 6), pageSize: ganz(1, 50), index: ganz(0, 10000) }, }; const MAX_KATALOG_BYTES = 4 * 1024 * 1024; async function katalogAnfrage(res, url, cfPfad, erlaubt) { const ziel = new URL(CF_API + cfPfad); if (erlaubt === KATALOG_PARAMETER.suche || erlaubt === KATALOG_PARAMETER.kategorien) ziel.searchParams.set('gameId', MC_SPIEL); for (const [name, wert] of url.searchParams) { const pruefer = erlaubt[name]; if (!pruefer || !pruefer(wert)) continue; // unbekannte oder ungültige Parameter einfach weglassen ziel.searchParams.set(name, wert); } const antwort = await fetch(ziel, { headers: cfKopfzeilen(), signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) }); 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 }); } const roh = await antwort.text(); if (roh.length > MAX_KATALOG_BYTES) return sendeJson(res, 502, { error: 'Antwort von CurseForge zu groß.' }); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(roh) }); res.end(roh); } 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 if (MAX_PARALLEL > 0 && laufendeDownloads >= MAX_PARALLEL) { res.setHeader('Retry-After', '5'); return sendeJson(res, 503, { error: 'Gerade zu viele Downloads, bitte gleich erneut versuchen.' }); } laufendeDownloads++; let frei = false; const freigeben = () => { if (!frei) { frei = true; laufendeDownloads--; } }; try { // Zeitlimit nur bis die Antwort-Köpfe da sind – der eigentliche Download darf länger dauern const abbruch = new AbortController(); const frist = setTimeout(() => abbruch.abort(), UPSTREAM_TIMEOUT_MS); res.on('close', () => abbruch.abort()); // Aufrufer weg -> CurseForge-Verbindung schließen let aktuell = ziel; let antwort = null; let umgeleitet = true; try { for (let sprung = 0; sprung < 4 && umgeleitet; sprung++) { antwort = await fetch(aktuell, { headers: cfKopfzeilen(), redirect: 'manual', signal: abbruch.signal }); const weiter = antwort.headers.get('location'); if (antwort.status >= 300 && antwort.status < 400 && weiter) { const naechste = pruefeDownloadZiel(new URL(weiter, aktuell).toString()); if (!naechste) { freigeben(); return sendeJson(res, 502, { error: 'Weiterleitung führt aus dem CurseForge-CDN heraus.' }); } aktuell = naechste; } else { umgeleitet = false; } } } finally { clearTimeout(frist); } if (umgeleitet) { freigeben(); return sendeJson(res, 502, { error: 'Zu viele Weiterleitungen.' }); } if (!antwort || !antwort.ok) { freigeben(); return sendeJson(res, antwort && antwort.status >= 400 ? antwort.status : 502, { error: 'Download fehlgeschlagen.' }); } const laenge = Number(antwort.headers.get('content-length')) || 0; const grenze = MAX_MB > 0 ? MAX_MB * 1024 * 1024 : 0; if (grenze && laenge > grenze) { freigeben(); 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) } : {}), }); // Auch ohne Content-Length (chunked) mitzählen und bei Überschreitung abbrechen, // sonst ließe sich die Größengrenze umgehen let gesamt = 0; const zaehlen = new Transform({ transform(stueck, _enc, fertig) { gesamt += stueck.length; if (grenze && gesamt > grenze) return fertig(new Error('Datei größer als erlaubt')); fertig(null, stueck); }, }); // pipeline räumt auf, wenn eine Seite abbricht (statt wie pipe() offen zu bleiben) pipeline(Readable.fromWeb(antwort.body), zaehlen, res, () => freigeben()); } catch (err) { freigeben(); throw err; } } // --------------------------------------------------------------------------- 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)) { // Fehlversuche je Absender begrenzen, damit sich das Token nicht in Ruhe erraten lässt if (!darfNoch('falsch:' + absender(req))) return sendeJson(res, 429, { error: 'Zu viele Anfragen, bitte kurz warten.' }); 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') { /* * Das Token steckt im Launcher und ist auslesbar. Jeder Selbsttest kostet fünf * CurseForge-Anfragen und läuft vor der Ratenbegrenzung – ohne Bremse ließe sich * damit das Kontingent des Schlüssels aufbrauchen. Deshalb höchstens alle * 30 Sekunden wirklich ausführen, dazwischen das letzte Ergebnis zeigen. */ const jetzt = Date.now(); if (selftestErgebnis && jetzt - selftestZeit < SELFTEST_ABSTAND_MS) { return sendeJson(res, 200, Object.assign({}, selftestErgebnis, { zwischengespeichert: true })); } 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, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); 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}`); selftestErgebnis = befund; selftestZeit = Date.now(); 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); if (req.method === 'GET' && url.pathname === '/v1/mods/search') return await katalogAnfrage(res, url, '/mods/search', KATALOG_PARAMETER.suche); if (req.method === 'GET' && url.pathname === '/v1/categories') return await katalogAnfrage(res, url, '/categories', KATALOG_PARAMETER.kategorien); const modDateien = req.method === 'GET' && /^\/v1\/mods\/(\d{1,9})\/files$/.exec(url.pathname); if (modDateien) return await katalogAnfrage(res, url, '/mods/' + modDateien[1] + '/files', KATALOG_PARAMETER.dateien); 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 – offen für jeden (CF_PROXY_OFFEN=1)'}`); 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 };