Files
AeroMc-Launcher/server/curseforge-proxy/index.js
2026-08-02 17:01:18 +02:00

345 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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, <echte-IP>" -> mit TRUST_PROXY=1 zählt <echte-IP>
*/
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 };