345 lines
14 KiB
JavaScript
345 lines
14 KiB
JavaScript
'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 };
|