diff --git a/server/curseforge-proxy/.env.example b/server/curseforge-proxy/.env.example index 31421ca..1eb26b4 100644 --- a/server/curseforge-proxy/.env.example +++ b/server/curseforge-proxy/.env.example @@ -33,6 +33,10 @@ RATE_LIMIT_PER_MIN=60 # Obergrenze für durchgereichte Dateien in MB. 0 = keine Grenze. MAX_DOWNLOAD_MB=512 +# Gleichzeitige Downloads insgesamt (weitere Anfragen bekommen 503 mit +# Retry-After). Schützt die Bandbreite. 0 = keine Grenze. +MAX_PARALLEL_DOWNLOADS=8 + # Anzahl der Reverse-Proxy-Stufen davor: # 0 = keine, der Dienst ist direkt erreichbar # 1 = ein Proxy, z. B. Nginx Proxy Manager oder Caddy diff --git a/server/curseforge-proxy/README.md b/server/curseforge-proxy/README.md index fd9f16b..5b8d7ae 100644 --- a/server/curseforge-proxy/README.md +++ b/server/curseforge-proxy/README.md @@ -100,7 +100,20 @@ Der Dienst steht offen im Netz. Eingebaut ist: - **Umleitungen** werden selbst verfolgt und bei jedem Sprung erneut gegen die Host-Liste geprüft, höchstens vier Sprünge. - **Ratenbegrenzung** je Absender, einstellbar über `RATE_LIMIT_PER_MIN`. -- **Größengrenze** für durchgereichte Dateien über `MAX_DOWNLOAD_MB`. +- **Größengrenze** für durchgereichte Dateien über `MAX_DOWNLOAD_MB`. Sie gilt + auch dann, wenn CurseForge keine Dateigröße angibt: Es wird mitgezählt und + bei Überschreitung abgebrochen. +- **Parallelgrenze** über `MAX_PARALLEL_DOWNLOADS` (Standard 8). Weitere + Downloads bekommen `503` mit `Retry-After`. +- **Zeitlimit** von 30 Sekunden für Antworten von CurseForge. Bricht der + Launcher mitten im Download ab, wird auch die Verbindung zu CurseForge + geschlossen. +- **Fehlversuche** beim Token zählen zur Ratenbegrenzung, ein Raten ist also + nicht unbegrenzt schnell möglich. +- **`/selftest`** läuft höchstens alle 30 Sekunden wirklich (fünf Anfragen an + CurseForge), dazwischen kommt das letzte Ergebnis mit + `"zwischengespeichert": true`. Das Token steckt im Launcher; ohne diese + Bremse ließe sich damit dein CurseForge-Kontingent aufbrauchen. - **Token** über `CF_PROXY_TOKEN` – Pflicht: ohne Token startet der Dienst nicht (bewusst offen nur mit `CF_PROXY_OFFEN=1`, z. B. im abgeschotteten Netz). diff --git a/server/curseforge-proxy/docker-compose.yml b/server/curseforge-proxy/docker-compose.yml index 7ed7fd2..4cf2116 100644 --- a/server/curseforge-proxy/docker-compose.yml +++ b/server/curseforge-proxy/docker-compose.yml @@ -12,3 +12,6 @@ services: read_only: true security_opt: - no-new-privileges:true + # keine Linux-Fähigkeiten nötig (Port 8420 liegt über 1024) + cap_drop: + - ALL diff --git a/server/curseforge-proxy/index.js b/server/curseforge-proxy/index.js index 92575d7..872beee 100644 --- a/server/curseforge-proxy/index.js +++ b/server/curseforge-proxy/index.js @@ -2,7 +2,7 @@ const http = require('http'); const crypto = require('crypto'); -const { Readable } = require('stream'); +const { Readable, Transform, pipeline } = require('stream'); const KEY = (process.env.CF_API_KEY || '').trim(); const PORT = Number(process.env.PORT || 8420); @@ -14,6 +14,15 @@ const TOKENS = new Set( ); const LIMIT_PRO_MIN = Number(process.env.RATE_LIMIT_PER_MIN || 60); const MAX_MB = Number(process.env.MAX_DOWNLOAD_MB || 512); +// gleichzeitige Downloads insgesamt – schützt die Bandbreite, falls jemand mit dem +// (im Launcher steckenden) Token viele große Dateien parallel zieht. 0 = keine Grenze. +const MAX_PARALLEL = Number(process.env.MAX_PARALLEL_DOWNLOADS ?? 8); +// wie lange CurseForge für eine Antwort brauchen darf, bevor die Anfrage abgebrochen wird +const UPSTREAM_TIMEOUT_MS = 30_000; +let laufendeDownloads = 0; +const SELFTEST_ABSTAND_MS = 30_000; +let selftestErgebnis = null; +let selftestZeit = 0; const PROXY_STUFEN = Math.max(0, Number(process.env.TRUST_PROXY || 0) || 0); @@ -157,6 +166,7 @@ async function dateilisteAufloesen(req, res) { method: 'POST', headers: cfKopfzeilen({ 'Content-Type': 'application/json' }), body: JSON.stringify({ fileIds: ids }), + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); if (!antwort.ok) { // Schlüsselprobleme nicht im Wortlaut weiterreichen @@ -171,33 +181,72 @@ async function dateiDurchreichen(req, res, 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; + if (MAX_PARALLEL > 0 && laufendeDownloads >= MAX_PARALLEL) { + res.setHeader('Retry-After', '5'); + return sendeJson(res, 503, { error: 'Gerade zu viele Downloads, bitte gleich erneut versuchen.' }); + } + laufendeDownloads++; + let frei = false; + const freigeben = () => { if (!frei) { frei = true; laufendeDownloads--; } }; + + try { + // Zeitlimit nur bis die Antwort-Köpfe da sind – der eigentliche Download darf länger dauern + const abbruch = new AbortController(); + const frist = setTimeout(() => abbruch.abort(), UPSTREAM_TIMEOUT_MS); + res.on('close', () => abbruch.abort()); // Aufrufer weg -> CurseForge-Verbindung schließen + + let aktuell = ziel; + let antwort = null; + let umgeleitet = true; + try { + for (let sprung = 0; sprung < 4 && umgeleitet; sprung++) { + antwort = await fetch(aktuell, { headers: cfKopfzeilen(), redirect: 'manual', signal: abbruch.signal }); + const weiter = antwort.headers.get('location'); + if (antwort.status >= 300 && antwort.status < 400 && weiter) { + const naechste = pruefeDownloadZiel(new URL(weiter, aktuell).toString()); + if (!naechste) { freigeben(); return sendeJson(res, 502, { error: 'Weiterleitung führt aus dem CurseForge-CDN heraus.' }); } + aktuell = naechste; + } else { + umgeleitet = false; + } + } + } finally { + clearTimeout(frist); + } + if (umgeleitet) { freigeben(); return sendeJson(res, 502, { error: 'Zu viele Weiterleitungen.' }); } + if (!antwort || !antwort.ok) { + freigeben(); + return sendeJson(res, antwort && antwort.status >= 400 ? antwort.status : 502, { error: 'Download fehlgeschlagen.' }); } - 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).` }); - } + const laenge = Number(antwort.headers.get('content-length')) || 0; + const grenze = MAX_MB > 0 ? MAX_MB * 1024 * 1024 : 0; + if (grenze && laenge > grenze) { + freigeben(); + 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); + res.writeHead(200, { + 'Content-Type': antwort.headers.get('content-type') || 'application/octet-stream', + ...(laenge ? { 'Content-Length': String(laenge) } : {}), + }); + + // Auch ohne Content-Length (chunked) mitzählen und bei Überschreitung abbrechen, + // sonst ließe sich die Größengrenze umgehen + let gesamt = 0; + const zaehlen = new Transform({ + transform(stueck, _enc, fertig) { + gesamt += stueck.length; + if (grenze && gesamt > grenze) return fertig(new Error('Datei größer als erlaubt')); + fertig(null, stueck); + }, + }); + // pipeline räumt auf, wenn eine Seite abbricht (statt wie pipe() offen zu bleiben) + pipeline(Readable.fromWeb(antwort.body), zaehlen, res, () => freigeben()); + } catch (err) { + freigeben(); + throw err; + } } // --------------------------------------------------------------------------- @@ -211,7 +260,11 @@ const server = http.createServer(async (req, res) => { return sendeJson(res, 200, { ok: true, dienst: 'aeromc-cf-proxy' }); } - if (!tokenOk(req)) return sendeJson(res, 401, { error: 'Nicht berechtigt.' }); + if (!tokenOk(req)) { + // Fehlversuche je Absender begrenzen, damit sich das Token nicht in Ruhe erraten lässt + if (!darfNoch('falsch:' + absender(req))) return sendeJson(res, 429, { error: 'Zu viele Anfragen, bitte kurz warten.' }); + return sendeJson(res, 401, { error: 'Nicht berechtigt.' }); + } /* * Hilfe beim Einstellen von TRUST_PROXY: zeigt, was der Dienst von DIESER @@ -249,6 +302,16 @@ const server = http.createServer(async (req, res) => { * niemals den Schlüssel selbst. */ if (url.pathname === '/selftest') { + /* + * Das Token steckt im Launcher und ist auslesbar. Jeder Selbsttest kostet fünf + * CurseForge-Anfragen und läuft vor der Ratenbegrenzung – ohne Bremse ließe sich + * damit das Kontingent des Schlüssels aufbrauchen. Deshalb höchstens alle + * 30 Sekunden wirklich ausführen, dazwischen das letzte Ergebnis zeigen. + */ + const jetzt = Date.now(); + if (selftestErgebnis && jetzt - selftestZeit < SELFTEST_ABSTAND_MS) { + return sendeJson(res, 200, Object.assign({}, selftestErgebnis, { zwischengespeichert: true })); + } const roh = process.env.CF_API_KEY || ''; const befund = { schluessel: { @@ -280,6 +343,7 @@ const server = http.createServer(async (req, res) => { method: p.method, headers: cfKopfzeilen(p.body ? { 'Content-Type': 'application/json' } : undefined), body: p.body ? JSON.stringify(p.body) : undefined, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); let text = ''; try { text = (await a.text()).replace(/\s+/g, ' ').trim().slice(0, 160); } catch { /* egal */ } @@ -312,6 +376,8 @@ const server = http.createServer(async (req, res) => { // Kurzfassung für den schnellen Blick befund.kurz = befund.proben.map((p) => `${p.name}: ${p.status ?? p.fehler}`); + selftestErgebnis = befund; + selftestZeit = Date.now(); return sendeJson(res, 200, befund); }