Upload via GUI (39 Dateien)

This commit is contained in:
Git Manager GUI
2026-08-02 17:01:18 +02:00
parent a803f297ea
commit eff6403dd5
26 changed files with 3536 additions and 134 deletions

View File

@@ -0,0 +1,40 @@
# Vorlage. Kopieren nach .env und ausfüllen.
# .env gehört NICHT in ein Repository (steht bereits in der .gitignore).
# Pflicht: dein CurseForge-API-Key von console.curseforge.com
CF_API_KEY=hier-deinen-schluessel-eintragen
# Port und Adresse, auf denen der Dienst lauscht.
# Steht ein Reverse-Proxy davor, reicht 127.0.0.1 als HOST.
PORT=8420
HOST=0.0.0.0
# Gemeinsames Geheimnis zwischen Launcher und Server. Hält Scanner und
# Zufallsfunde zuverlässig ab deine Domain steht durch das TLS-Zertifikat
# öffentlich in den Certificate-Transparency-Listen und wird gefunden.
# Gegen jemanden, der gezielt die .exe auspackt, hilft es nicht.
#
# Mehrere Token durch Komma getrennt. Das brauchst du beim Wechseln:
# altes und neues eine Weile parallel eintragen, damit ältere Installationen
# weiterlaufen, bis alle das Update haben danach das alte streichen.
# CF_PROXY_TOKEN=neues-token,altes-token
#
# Leer lassen = keine Tokenprüfung.
CF_PROXY_TOKEN=
# Anfragen je Minute und Absender. 0 = keine Begrenzung.
RATE_LIMIT_PER_MIN=60
# Obergrenze für durchgereichte Dateien in MB. 0 = keine Grenze.
MAX_DOWNLOAD_MB=512
# Anzahl der Reverse-Proxy-Stufen davor:
# 0 = keine, der Dienst ist direkt erreichbar
# 1 = ein Proxy, z. B. Nginx Proxy Manager oder Caddy
# 2 = zwei, z. B. Cloudflare vor dem Nginx Proxy Manager
#
# Die Zahl zählt, nicht nur "an oder aus": nginx hängt die echte Adresse an
# X-Forwarded-For an, statt sie zu ersetzen. Gezählt wird deshalb von rechts.
# Zu hoch angesetzt, könnte ein Aufrufer eine Adresse erfinden und die
# Ratenbegrenzung umgehen im Zweifel lieber 0.
TRUST_PROXY=0

View File

@@ -0,0 +1,19 @@
FROM node:22-alpine
# keine Abhängigkeiten das Programm nutzt nur Node-Bordmittel
WORKDIR /app
COPY index.js ./
ENV PORT=8420 \
HOST=0.0.0.0 \
NODE_ENV=production
# nicht als root laufen
USER node
EXPOSE 8420
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8420)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "index.js"]

View File

@@ -0,0 +1,160 @@
# AeroMC CurseForge-Weiterleitung
Kleiner Dienst, der den CurseForge-API-Schlüssel hält und die Anfragen
stellvertretend für den Launcher stellt.
## Wozu das Ganze
CurseForge erlaubt es nicht, den API-Schlüssel weiterzugeben. In einer
verteilten `.exe` wäre er für jeden auslesbar das Entpacken einer
Electron-Anwendung dauert keine Minute. Deshalb bleibt der Schlüssel hier
auf dem Server, und der Launcher kennt nur die Adresse dieses Dienstes.
## Wege
| Weg | Zweck |
|---|---|
| `GET /health` | Lebenszeichen, verrät nichts über den Schlüssel |
| `POST /v1/mods/files` | Dateiliste auflösen, Rumpf `{ "fileIds": [...] }`, höchstens 100 je Anfrage |
| `GET /v1/download?url=…` | Mod-Datei durchreichen; das CurseForge-CDN verlangt seit Juli 2026 ebenfalls einen Schlüssel |
## Einrichten
```bash
cp .env.example .env
# CF_API_KEY eintragen
```
### Ohne Docker
Braucht nur Node 18 oder neuer, keine Pakete.
```bash
node index.js
```
Dauerhaft über systemd: `aeromc-cf-proxy.service` nach
`/etc/systemd/system/` kopieren, Pfade und Benutzer darin anpassen, dann
```bash
systemctl daemon-reload
systemctl enable --now aeromc-cf-proxy
```
### Mit pm2
`ecosystem.config.js` liegt bei und liest die `.env` selbst ein pm2 tut das
von sich aus nämlich nicht.
```bash
pm2 start ecosystem.config.js
pm2 save
pm2 startup # einmalig, für den Start nach einem Neustart
```
Nach jeder Änderung an der `.env`:
```bash
pm2 restart aeromc-cf-proxy --update-env
```
Ohne `--update-env` behält pm2 die alten Werte, und du suchst lange nach dem
Grund, warum der neue Schlüssel nicht greift.
Nachsehen, ob er läuft:
```bash
pm2 logs aeromc-cf-proxy --lines 20
```
**Nicht auf Cluster-Betrieb umstellen.** Die Konfiguration steht bewusst auf
`exec_mode: 'fork'` mit einem Prozess. Die Ratenbegrenzung zählt im
Arbeitsspeicher bei vier Arbeitern hätte jeder seine eigene Zählung, und
die Grenze wäre in Wahrheit viermal so hoch wie eingestellt.
### Mit Docker
```bash
docker compose up -d
```
## Launcher einstellen
In den Launcher-Einstellungen unter **Netzwerk → CurseForge** die Adresse
eintragen, zum Beispiel `https://cf.example.de`. Das Feld „Eigener API-Key"
bleibt leer wer dort einen Schlüssel einträgt, geht direkt zu CurseForge
und nutzt diesen Dienst nicht.
Soll die Adresse für alle Nutzer voreingestellt sein, trage sie in
`src/services.js` bei `STANDARD_WEITERLEITUNG` ein. Die Adresse ist kein
Geheimnis, nur der Schlüssel ist eines.
## Absicherung
Der Dienst steht offen im Netz. Eingebaut ist:
- **Host-Liste** durchgereicht wird nur an `edge.forgecdn.net`,
`mediafilez.forgecdn.net` und `media.forgecdn.net`. Ohne diese Prüfung
könnte jemand den Dienst Adressen in deinem Heimnetz abrufen lassen und
die Antwort mitlesen.
- **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`.
- **Optionales Token** über `CF_PROXY_TOKEN`.
Zum Token eine ehrliche Einordnung: Es steckt dann auch im Launcher und ist
dort auslesbar. Es hält Gelegenheitsnutzer ab, keine entschlossenen. Die
Ratenbegrenzung ist der wirksamere Schutz für dein Kontingent.
### Reverse-Proxy: `TRUST_PROXY` richtig setzen
`TRUST_PROXY` ist keine Ja/Nein-Angabe, sondern die **Anzahl der Stufen** vor
dem Dienst: `0` ohne Proxy, `1` mit Nginx Proxy Manager, `2` mit Cloudflare
davor.
Der Grund: nginx **ergänzt** `X-Forwarded-For`, statt es zu ersetzen. Schickt
ein Aufrufer selbst `X-Forwarded-For: 1.2.3.4`, steht hinterher
`1.2.3.4, <echte-IP>` darin. Deshalb wird von rechts gezählt die eigenen
Proxy-Stufen sind die einzigen Einträge, die niemand von außen setzen kann.
Zu hoch angesetzt, greift der Dienst auf einen Eintrag zu, den der Aufrufer
selbst erfunden hat: Dann kann sich jeder mit jeder Anfrage eine neue Adresse
ausdenken und die Ratenbegrenzung ins Leere laufen lassen. Im Zweifel `0`.
### Nginx Proxy Manager
Im Proxy Host eintragen:
| Feld | Wert |
|---|---|
| Scheme | `http` |
| Forward Hostname / IP | Containername oder LAN-Adresse des Dienstes |
| Forward Port | `8420` |
| Block Common Exploits | an |
| Websockets Support | nicht nötig |
Im Reiter **SSL**: Let's-Encrypt-Zertifikat anfordern, „Force SSL" und
HTTP/2 aktivieren. Ohne HTTPS ginge das Token im Klartext über die Leitung.
Im Reiter **Advanced** empfiehlt sich für die durchgereichten Mod-Dateien:
```nginx
proxy_buffering off;
proxy_read_timeout 300s;
```
Ohne `proxy_buffering off` schreibt nginx große Dateien erst vollständig in
eine temporäre Datei, bevor der Launcher das erste Byte sieht bei
mehreren hundert MB fühlt sich das wie ein Hänger an.
Läuft der Nginx Proxy Manager selbst in Docker, gehören beide ins gleiche
Docker-Netz und der Port muss gar nicht nach außen gebunden werden: In der
`docker-compose.yml` dann den `ports`-Block weglassen. Sonst wenigstens auf
`127.0.0.1:8420:8420` einschränken, damit der Dienst nicht an der Absicherung
des Proxys vorbei erreichbar ist.
## Was der Dienst nicht tut
Er speichert nichts, protokolliert keine Adressen der Nutzer und gibt
Schlüsselfehler von CurseForge nicht im Wortlaut nach außen weiter.

View File

@@ -0,0 +1,25 @@
[Unit]
Description=AeroMC CurseForge-Weiterleitung
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Pfad und Benutzer anpassen
WorkingDirectory=/opt/aeromc-cf-proxy
EnvironmentFile=/opt/aeromc-cf-proxy/.env
ExecStart=/usr/bin/node /opt/aeromc-cf-proxy/index.js
User=aeromc
Group=aeromc
Restart=always
RestartSec=5
# Absicherung: der Dienst braucht keinen Schreibzugriff und keine neuen Rechte
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,14 @@
services:
aeromc-cf-proxy:
build: .
container_name: aeromc-cf-proxy
restart: unless-stopped
env_file: .env
ports:
# nach außen nur binden, wenn kein Reverse-Proxy davorsteht.
# Mit Reverse-Proxy besser: "127.0.0.1:8420:8420"
- "8420:8420"
# der Dienst braucht nichts zu schreiben
read_only: true
security_opt:
- no-new-privileges:true

View File

@@ -0,0 +1,54 @@
'use strict';
/*
* pm2-Konfiguration für die CurseForge-Weiterleitung.
*
* pm2 start ecosystem.config.js
* pm2 save
*
* pm2 liest .env-Dateien nicht von selbst, deshalb tut es diese Datei.
* So bleibt der Schlüssel in der .env und steht nicht in der Konfiguration,
* die man eher mal weitergibt oder eincheckt.
*/
const fs = require('fs');
const path = require('path');
// kleiner .env-Leser Kommentare, Anführungszeichen und Werte mit "=" inklusive
function leseEnv(datei) {
const werte = {};
if (!fs.existsSync(datei)) return werte;
for (const zeile of fs.readFileSync(datei, 'utf8').split(/\r?\n/)) {
const t = zeile.trim();
if (!t || t.startsWith('#')) continue;
const i = t.indexOf('=');
if (i < 1) continue;
const name = t.slice(0, i).trim();
let wert = t.slice(i + 1).trim();
const q = wert[0];
if ((q === '"' || q === "'") && wert.endsWith(q) && wert.length > 1) wert = wert.slice(1, -1);
werte[name] = wert;
}
return werte;
}
module.exports = {
apps: [{
name: 'aeromc-cf-proxy',
script: 'index.js',
cwd: __dirname,
// Fork-Betrieb mit genau einem Prozess. Im Cluster-Betrieb hätte jeder
// Arbeiter seine eigene Zählung die Ratenbegrenzung wäre dann bei
// vier Prozessen viermal so hoch wie eingestellt.
exec_mode: 'fork',
instances: 1,
autorestart: true,
max_restarts: 10,
restart_delay: 3000,
max_memory_restart: '256M',
env: leseEnv(path.join(__dirname, '.env')),
}],
};

View 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 };