From eff6403dd5966eec8922f7c43de0855e442597f4 Mon Sep 17 00:00:00 2001 From: Git Manager GUI Date: Sun, 2 Aug 2026 17:01:18 +0200 Subject: [PATCH] Upload via GUI (39 Dateien) --- README.md | 4 +- package.json | 6 +- scripts/write-build-config.js | 43 ++ server/curseforge-proxy/.env.example | 40 ++ server/curseforge-proxy/Dockerfile | 19 + server/curseforge-proxy/README.md | 160 +++++ .../curseforge-proxy/aeromc-cf-proxy.service | 25 + server/curseforge-proxy/docker-compose.yml | 14 + server/curseforge-proxy/ecosystem.config.js | 54 ++ server/curseforge-proxy/index.js | 344 ++++++++++ src/build-config.json | 4 + src/crashreports.js | 180 ++++++ src/index.html | 216 ++++++- src/javadl.js | 212 ++++++ src/launcher.js | 27 +- src/main.js | 536 +++++++++++++++- src/mmcimport.js | 94 ++- src/nbt.js | 174 +++++ src/preload.js | 41 ++ src/renderer.js | 603 +++++++++++++++++- src/servers.js | 146 +++++ src/services.js | 203 +++++- src/skins.js | 121 ++++ src/store.js | 88 ++- src/styles.css | 20 + src/updater.js | 296 +++++++-- 26 files changed, 3536 insertions(+), 134 deletions(-) create mode 100644 scripts/write-build-config.js create mode 100644 server/curseforge-proxy/.env.example create mode 100644 server/curseforge-proxy/Dockerfile create mode 100644 server/curseforge-proxy/README.md create mode 100644 server/curseforge-proxy/aeromc-cf-proxy.service create mode 100644 server/curseforge-proxy/docker-compose.yml create mode 100644 server/curseforge-proxy/ecosystem.config.js create mode 100644 server/curseforge-proxy/index.js create mode 100644 src/build-config.json create mode 100644 src/crashreports.js create mode 100644 src/javadl.js create mode 100644 src/nbt.js create mode 100644 src/servers.js create mode 100644 src/skins.js diff --git a/README.md b/README.md index 65623b2..244b243 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung und Microsoft-Anmeldung. -Aktueller Stand: Version 1.0.1 Beta +Aktueller Stand: Version 1.0.3 @@ -103,7 +103,7 @@ Der Bedrock-Bereich ist für bereits installierte Bedrock-Versionen unter Window ## Links -- Webseite: https://M_Viper.de +- Webseite: https://m-viper.de - Gitseite: https://git.viper.ipv64.net - Autor: M_Viper diff --git a/package.json b/package.json index 88aa248..f10bfa9 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "aeromc", "productName": "AeroMC", - "version": "1.0.1-beta", + "version": "1.0.3", "description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)", - "homepage": "https://M_Viper.de", + "homepage": "https://m-viper.de", "main": "src/main.js", "author": "M_Viper", "repository": { @@ -13,6 +13,8 @@ "license": "MIT", "scripts": { "start": "electron .", + "prebuild": "node scripts/write-build-config.js", + "prebuild:portable": "node scripts/write-build-config.js", "build": "electron-builder --win nsis --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Setup.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\"", "build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\"" }, diff --git a/scripts/write-build-config.js b/scripts/write-build-config.js new file mode 100644 index 0000000..5ac5f8f --- /dev/null +++ b/scripts/write-build-config.js @@ -0,0 +1,43 @@ +'use strict'; + +/* + * write-build-config.js – Vorgaben aus der Umgebung ins Paket schreiben + * --------------------------------------------------------------------- + * Läuft automatisch vor jedem Build und erzeugt src/build-config.json. + * + * Damit landen Adresse und Token der CurseForge-Weiterleitung im fertigen + * Paket, ohne je im Quelltext oder in einem Repository zu stehen. Gesetzt + * werden sie beim Bauen als Umgebungsvariablen: + * + * PowerShell: + * $env:AEROMC_CF_PROXY_URL = "https://cf.example.de" + * $env:AEROMC_CF_PROXY_TOKEN = "..." + * npm run build + * + * bash: + * AEROMC_CF_PROXY_URL=https://cf.example.de AEROMC_CF_PROXY_TOKEN=... npm run build + * + * Ohne gesetzte Variablen wird eine leere Vorgabe geschrieben – der Launcher + * verhält sich dann wie bisher und jeder Nutzer trägt selbst etwas ein. + * + * Zur Ehrlichkeit: Ein Token im ausgelieferten Paket ist auslesbar. Es hält + * Gelegenheitsnutzer ab, keine entschlossenen. Der API-Schlüssel selbst + * bleibt davon unberührt – der liegt ausschließlich auf dem Server. + */ + +const fs = require('fs'); +const path = require('path'); + +const ziel = path.join(__dirname, '..', 'src', 'build-config.json'); + +const config = { + curseforgeProxyUrl: String(process.env.AEROMC_CF_PROXY_URL || '').trim().replace(/\/+$/, ''), + curseforgeProxyToken: String(process.env.AEROMC_CF_PROXY_TOKEN || '').trim(), +}; + +fs.writeFileSync(ziel, JSON.stringify(config, null, 2) + '\n', 'utf8'); + +// Den Token selbst nie ausgeben – Build-Protokolle landen schnell woanders. +const wo = config.curseforgeProxyUrl || '(keine)'; +const tok = config.curseforgeProxyToken ? 'gesetzt (' + config.curseforgeProxyToken.length + ' Zeichen)' : 'keins'; +console.log(`build-config.json geschrieben – Weiterleitung: ${wo}, Token: ${tok}`); diff --git a/server/curseforge-proxy/.env.example b/server/curseforge-proxy/.env.example new file mode 100644 index 0000000..fced0a2 --- /dev/null +++ b/server/curseforge-proxy/.env.example @@ -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 diff --git a/server/curseforge-proxy/Dockerfile b/server/curseforge-proxy/Dockerfile new file mode 100644 index 0000000..f8b30fd --- /dev/null +++ b/server/curseforge-proxy/Dockerfile @@ -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"] diff --git a/server/curseforge-proxy/README.md b/server/curseforge-proxy/README.md new file mode 100644 index 0000000..bc8139d --- /dev/null +++ b/server/curseforge-proxy/README.md @@ -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, ` 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. diff --git a/server/curseforge-proxy/aeromc-cf-proxy.service b/server/curseforge-proxy/aeromc-cf-proxy.service new file mode 100644 index 0000000..c86ed9b --- /dev/null +++ b/server/curseforge-proxy/aeromc-cf-proxy.service @@ -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 diff --git a/server/curseforge-proxy/docker-compose.yml b/server/curseforge-proxy/docker-compose.yml new file mode 100644 index 0000000..7ed7fd2 --- /dev/null +++ b/server/curseforge-proxy/docker-compose.yml @@ -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 diff --git a/server/curseforge-proxy/ecosystem.config.js b/server/curseforge-proxy/ecosystem.config.js new file mode 100644 index 0000000..623c042 --- /dev/null +++ b/server/curseforge-proxy/ecosystem.config.js @@ -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')), + }], +}; diff --git a/server/curseforge-proxy/index.js b/server/curseforge-proxy/index.js new file mode 100644 index 0000000..42dac5c --- /dev/null +++ b/server/curseforge-proxy/index.js @@ -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, " -> mit TRUST_PROXY=1 zählt + */ +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 }; diff --git a/src/build-config.json b/src/build-config.json new file mode 100644 index 0000000..2bdbb6a --- /dev/null +++ b/src/build-config.json @@ -0,0 +1,4 @@ +{ + "curseforgeProxyUrl": "", + "curseforgeProxyToken": "GetqSQ6K0z4OHet2SCUA5dHBqoKmFCl3a7LT6bZVVS0FAb07ZxVq7r7HS3dUbH6J" +} diff --git a/src/crashreports.js b/src/crashreports.js new file mode 100644 index 0000000..823be54 --- /dev/null +++ b/src/crashreports.js @@ -0,0 +1,180 @@ +'use strict'; + +/* + * crashreports.js – Absturzberichte von Minecraft auswerten + * ---------------------------------------------------------- + * Minecraft legt bei einem Absturz eine Datei unter + * /crash-reports/crash--client.txt an. Die enthält + * hunderte Zeilen Systemangaben, aber nur wenige, die weiterhelfen. + * + * Dieses Modul sucht den neuesten Bericht und zieht daraus heraus: + * Beschreibung, Fehlerart, verdächtige Mods und die ersten Stellen im + * Aufrufverlauf. + */ + +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); + +// größere Berichte gibt es kaum; die Grenze schützt vor Ausreißern +const MAX_BYTES = 2 * 1024 * 1024; + +function berichtsOrdner(gameDir) { + return path.join(gameDir, 'crash-reports'); +} + +/* + * Neuester Bericht im Ordner. seit: nur Berichte ab diesem Zeitpunkt + * (Millisekunden) – sonst zeigt man nach einem sauberen Start den Absturz + * von vorgestern an. + */ +function neuesterBericht(gameDir, seit) { + const dir = berichtsOrdner(gameDir); + let eintraege = []; + try { eintraege = fs.readdirSync(dir, { withFileTypes: true }); } catch { return null; } + + let bester = null; + for (const e of eintraege) { + if (!e.isFile() || !/^crash-.*\.txt$/i.test(e.name)) continue; + const voll = path.join(dir, e.name); + let st; + try { st = fs.statSync(voll); } catch { continue; } + if (seit && st.mtimeMs < seit) continue; + if (!bester || st.mtimeMs > bester.mtimeMs) bester = { pfad: voll, name: e.name, mtimeMs: st.mtimeMs, groesse: st.size }; + } + return bester; +} + +/* + * Zieht die aussagekräftigen Zeilen aus einem Bericht. + * -> { beschreibung, fehler, verdaechtigeMods, verlauf: [] } + */ +function auswerten(text) { + const zeilen = String(text || '').split(/\r?\n/); + const befund = { beschreibung: '', fehler: '', verdaechtigeMods: '', verlauf: [] }; + + for (let i = 0; i < zeilen.length; i++) { + const z = zeilen[i]; + const roh = z.trim(); + + if (!befund.beschreibung && /^Description:/i.test(roh)) { + befund.beschreibung = roh.replace(/^Description:\s*/i, '').trim(); + // Die Fehlerzeile steht direkt darunter, nach einer Leerzeile. + for (let j = i + 1; j < Math.min(i + 6, zeilen.length); j++) { + const k = zeilen[j].trim(); + if (!k) continue; + befund.fehler = k; + break; + } + continue; + } + + // Forge/NeoForge nennen Verdächtige ausdrücklich + if (!befund.verdaechtigeMods && /^Suspected Mods?:/i.test(roh)) { + befund.verdaechtigeMods = roh.replace(/^Suspected Mods?:\s*/i, '').trim(); + continue; + } + + // die obersten Stellen im Aufrufverlauf – dort steht meist der Verursacher + if (befund.verlauf.length < 8 && /^at\s+\S/.test(roh)) { + befund.verlauf.push(roh); + } + } + + // Manche Berichte nennen keine Description, dafür eine Ausnahme weiter oben + if (!befund.fehler) { + const treffer = zeilen.find((z) => /^[\w.$]+(Exception|Error)(:|\s|$)/.test(z.trim())); + if (treffer) befund.fehler = treffer.trim(); + } + return befund; +} + +/* + * Sucht und liest den neuesten Bericht. + * -> { ok: false, grund } oder { ok: true, datei, zeitpunkt, ...auswertung, text } + */ +function lies(gameDir, seit) { + const bericht = neuesterBericht(gameDir, seit); + if (!bericht) return { ok: false, grund: 'kein-bericht' }; + if (bericht.groesse > MAX_BYTES) return { ok: false, grund: 'zu-gross', datei: bericht.name }; + + let text = ''; + try { text = fs.readFileSync(bericht.pfad, 'utf8'); } + catch (err) { return { ok: false, grund: 'nicht-lesbar', message: err.message }; } + + return Object.assign({ + ok: true, + datei: bericht.name, + pfad: bericht.pfad, + zeitpunkt: new Date(bericht.mtimeMs).toISOString(), + text, + }, auswerten(text)); +} + +/* + * Logdateien der Instanz auflisten. Minecraft schreibt latest.log und legt + * ältere Läufe gepackt als -.log.gz daneben. + * -> [{ name, groesse, geaendert, gepackt }] – neueste zuerst + */ +function listeLogs(gameDir) { + const dir = path.join(gameDir, 'logs'); + let eintraege = []; + try { eintraege = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; } + + const logs = []; + for (const e of eintraege) { + if (!e.isFile()) continue; + const gepackt = /\.log\.gz$/i.test(e.name); + if (!gepackt && !/\.log$/i.test(e.name)) continue; + try { + const st = fs.statSync(path.join(dir, e.name)); + logs.push({ name: e.name, groesse: st.size, geaendert: st.mtimeMs, gepackt }); + } catch { /* überspringen */ } + } + // latest.log immer zuoberst, danach nach Datum + logs.sort((a, b) => { + if (a.name.toLowerCase() === 'latest.log') return -1; + if (b.name.toLowerCase() === 'latest.log') return 1; + return b.geaendert - a.geaendert; + }); + return logs; +} + +/* + * Inhalt einer Logdatei. Gepackte werden entpackt. + * maxZeilen begrenzt die Rückgabe – ein Log kann zehntausende Zeilen haben, + * und die Oberfläche soll dabei nicht einfrieren. + */ +function liesLog(gameDir, name, maxZeilen = 5000) { + const dir = path.join(gameDir, 'logs'); + const voll = path.join(dir, path.basename(String(name || ''))); + if (path.dirname(voll) !== dir) return { ok: false, grund: 'bad-name' }; + if (!fs.existsSync(voll)) return { ok: false, grund: 'nicht-gefunden' }; + + let text = ''; + try { + if (/\.gz$/i.test(voll)) text = zlib.gunzipSync(fs.readFileSync(voll)).toString('utf8'); + else text = fs.readFileSync(voll, 'utf8'); + } catch (err) { + return { ok: false, grund: 'nicht-lesbar', message: err.message }; + } + + const zeilen = text.split(/\r?\n/); + const gekuerzt = zeilen.length > maxZeilen; + // bei langen Logs das Ende zeigen – dort steht, was zuletzt passierte + const sichtbar = gekuerzt ? zeilen.slice(zeilen.length - maxZeilen) : zeilen; + + return { + ok: true, + name: path.basename(voll), + zeilenGesamt: zeilen.length, + gekuerzt, + text: sichtbar.join('\n'), + }; +} + +function logOrdner(gameDir) { + return path.join(gameDir, 'logs'); +} + +module.exports = { lies, auswerten, neuesterBericht, berichtsOrdner, listeLogs, liesLog, logOrdner }; diff --git a/src/index.html b/src/index.html index abebfb5..acea5c9 100644 --- a/src/index.html +++ b/src/index.html @@ -51,6 +51,8 @@ + +
@@ -154,6 +156,7 @@ + + + Holt beim Spielstart die passende Fassung von Eclipse Temurin, wenn keine geeignete Java-Installation gefunden wird. Ablage im Launcher-Ordner, rund 45 MB je Version.
@@ -400,6 +407,26 @@
+ +
+
CurseForge
+ +
+ + +
+ Ein Server, der den Schlüssel für dich hält. Ohne beides funktionieren Modrinth-Modpacks (.mrpack) weiterhin. +
@@ -439,6 +466,31 @@ +
+
Automatisch sichern
+ +
+ + +
+ + Eine Sicherung umfasst alle Instanzen und kann je nach Umfang einige Minuten dauern. Vor dem Spielstart wird sie abgewartet – der Start verzögert sich entsprechend. Ältere automatische Sicherungen werden gelöscht, sobald die eingestellte Anzahl überschritten ist. + Noch keine automatische Sicherung. +
+
Wiederherstellen
Wähle ein Backup aus Dokumente/AeroMC Launcher/Backups aus. Achtung: Alle vorhandenen Instanzen werden beim Wiederherstellen mit dem Inhalt des Backups überschrieben. Launcher-Einstellungen werden ebenfalls wiederhergestellt. Konten und Tokens bleiben unverändert. @@ -473,7 +525,7 @@
- Kein Update-Check ausgefuehrt. + Kein Update-Check ausgeführt.
@@ -560,6 +612,11 @@ @@ -589,9 +646,10 @@

Warte auf Bestätigung …

-

Melde dich mit deinem Microsoft-Konto an (Minecraft Java Edition nötig). Mehrere Konten sind möglich – das aktive wird zum Spielen verwendet.

+

Melde dich mit deinem Microsoft-Konto an (Minecraft Java Edition nötig). Das aktive Konto wird beim nächsten Start verwendet. Für zwei Konten gleichzeitig: eine Instanz starten, hier das zweite Konto aktivieren und eine weitere Instanz starten – laufende Spiele behalten ihr Konto.

+ + + + + + + + + + + + + + + @@ -707,13 +907,13 @@
-

Importiert eine exportierte AeroMC-Instanz aus einem ZIP-Archiv.

-

Wähle ein Archiv mit einer gültigen instance.json. Der Launcher legt daraus automatisch eine neue Instanz an.

+

Importiert eine einzelne Instanz aus einem ZIP-Archiv.

+

Erkannt werden AeroMC-Exporte (instance.json) und einzelne MultiMC- oder Prism-Instanzen (instance.cfg) – letztere auch als gezippter Instanzordner. Der Launcher legt daraus automatisch eine neue Instanz an, samt Mods, Welten und Konfiguration.