Files
AeroMc-Launcher/server/curseforge-proxy/index.js
T
2026-10-11 07:05:41 +00:00

340 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';
const http = require('http');
const crypto = require('crypto');
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);
}
// 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, <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);
}
// 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 }),
});
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 – 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 };