Files
AeroMc-Launcher/server/curseforge-proxy/index.js
T
2026-10-07 07:31:59 +00:00

316 lines
12 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';
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';
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);
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);
}
// ---------------------------------------------------------------------------
// 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 };