Upload via GUI (39 Dateien)
This commit is contained in:
344
server/curseforge-proxy/index.js
Normal file
344
server/curseforge-proxy/index.js
Normal file
@@ -0,0 +1,344 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user