228 lines
11 KiB
JavaScript
228 lines
11 KiB
JavaScript
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
const readline = require('readline');
|
||
const { execFileSync } = require('child_process');
|
||
const { signaturText, pruefeSignatur, holeSignatur, SIGNATUR_API } = require('../src/updater');
|
||
|
||
const ROOT = path.join(__dirname, '..');
|
||
const DIST = path.join(ROOT, 'dist');
|
||
const ORDNER = path.join(os.homedir(), '.aeromc-release');
|
||
const OEFFENTLICH = path.join(ROOT, 'src', 'update-schluessel.pem');
|
||
const PRIVAT = process.env.AEROMC_SIGN_KEY || path.join(ORDNER, 'release-key.pem');
|
||
const ZUGANG = path.join(ORDNER, 'wordpress.json');
|
||
const DATEIEN = { setup: 'AeroMC-Setup.exe', portable: 'AeroMC-Portable.exe' };
|
||
const WP_STANDARD = new URL(SIGNATUR_API).origin;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Schlüssel
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function schluesselAnlegen() {
|
||
if (fs.existsSync(PRIVAT)) {
|
||
console.log(`Es gibt bereits einen Release-Schlüssel: ${PRIVAT}`);
|
||
console.log('Er wird nicht überschrieben – sonst nähmen bestehende Launcher keine Updates mehr an.');
|
||
return;
|
||
}
|
||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
||
fs.mkdirSync(path.dirname(PRIVAT), { recursive: true });
|
||
fs.writeFileSync(PRIVAT, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 });
|
||
fs.writeFileSync(OEFFENTLICH, publicKey.export({ type: 'spki', format: 'pem' }));
|
||
console.log(`Privater Schlüssel: ${PRIVAT} (SICHERN, niemals weitergeben!)`);
|
||
console.log(`Öffentlicher Schlüssel: ${OEFFENTLICH} (wird in den Launcher eingebaut)`);
|
||
}
|
||
|
||
|
||
function dpapi(skript, eingabe) {
|
||
return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', skript],
|
||
{ input: eingabe, encoding: 'utf8', windowsHide: true }).trim();
|
||
}
|
||
function verschluesseln(text) {
|
||
return dpapi('$t = [Console]::In.ReadToEnd(); ConvertTo-SecureString $t -AsPlainText -Force | ConvertFrom-SecureString', text);
|
||
}
|
||
function entschluesseln(hex) {
|
||
return dpapi('$h = [Console]::In.ReadToEnd().Trim(); $s = ConvertTo-SecureString $h; '
|
||
+ '[Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))', hex);
|
||
}
|
||
|
||
function frage(text, verdeckt) {
|
||
return new Promise((resolve) => {
|
||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
||
if (verdeckt) {
|
||
rl._writeToOutput = (s) => { if (s.includes(text)) process.stdout.write(text); else if (s === '\r\n' || s === '\n') process.stdout.write(s); };
|
||
}
|
||
rl.question(text, (antwort) => { rl.close(); if (verdeckt) process.stdout.write('\n'); resolve(antwort.trim()); });
|
||
});
|
||
}
|
||
|
||
function ladeZugang() {
|
||
if (!fs.existsSync(ZUGANG)) return null;
|
||
const z = JSON.parse(fs.readFileSync(ZUGANG, 'utf8').replace(/^/, ''));
|
||
return { seite: z.seite, benutzer: z.benutzer, passwort: entschluesseln(z.passwort) };
|
||
}
|
||
|
||
function kopfzeilen(zugang) {
|
||
const daten = Buffer.from(`${zugang.benutzer}:${zugang.passwort}`).toString('base64');
|
||
return {
|
||
Authorization: 'Basic ' + daten,
|
||
// Ersatz für Server, die "Authorization" nicht an PHP weitergeben (siehe signaturen.php)
|
||
'X-AeroMC-Auth': daten,
|
||
'Content-Type': 'application/json',
|
||
'User-Agent': 'AeroMC-Release',
|
||
};
|
||
}
|
||
|
||
// Übersetzt die Diagnose des Plugins (ab 1.5.4) in eine klare Meldung
|
||
function zugangsFehler(d, benutzer) {
|
||
if (!d || !d.version) {
|
||
return 'Das Plugin auf der Seite ist zu alt – bitte "AeroMC Freunde" 1.5.4 oder neuer installieren.';
|
||
}
|
||
if (!d.anwendungspasswoerter) return 'Anwendungspasswörter sind auf der Seite abgeschaltet (z. B. durch ein Sicherheits-Plugin).';
|
||
if (!d.authorization_kommt_an && !d.eigener_kopf_kommt_an) {
|
||
return 'Der Server leitet keine Anmeldedaten an WordPress weiter (beide Header fehlen) – das muss der Hoster freigeben.';
|
||
}
|
||
switch (d.grund) {
|
||
case 'invalid_username': return `Den Benutzer "${benutzer}" gibt es nicht (Benutzername, nicht E-Mail-Adresse).`;
|
||
case 'incorrect_password':
|
||
return 'Falsches Anwendungspasswort. Achtung: Gemeint ist NICHT das Login-Passwort, sondern das unter '
|
||
+ 'Benutzer → Profil → "Anwendungspasswörter" erzeugte (Form "abcd efgh ijkl …").';
|
||
case 'application_passwords_disabled':
|
||
case 'application_passwords_disabled_for_user':
|
||
return `Für "${benutzer}" sind Anwendungspasswörter abgeschaltet.`;
|
||
default: break;
|
||
}
|
||
if (d.benutzer && !d.darf_hochladen) {
|
||
return `Anmeldung als "${d.benutzer}" klappt, aber der Benutzer hat nicht die Rolle "AeroMC Release" (Benutzer bearbeiten → Rolle).`;
|
||
}
|
||
return 'Anmeldung abgelehnt' + (d.grund ? ` (${d.grund})` : '') + '.';
|
||
}
|
||
|
||
async function pruefeZugang(zugang) {
|
||
const res = await fetch(`${zugang.seite}/wp-json/aeromc/v1/update-signatur/zugang`, { headers: kopfzeilen(zugang) });
|
||
if (res.status === 404) throw new Error('Die Seite kennt die Signatur-Schnittstelle nicht – ist das Plugin "AeroMC Freunde" (ab 1.5.4) installiert und aktiviert?');
|
||
let d = null;
|
||
try { d = await res.json(); } catch { /* keine JSON-Antwort */ }
|
||
if (res.ok && d && d.ok) return d;
|
||
if (res.status === 401 || res.status === 403) throw new Error(zugangsFehler(d, zugang.benutzer));
|
||
throw new Error('WordPress antwortet mit HTTP ' + res.status);
|
||
}
|
||
|
||
async function wordpressEinrichten() {
|
||
console.log('WordPress-Zugang für das automatische Hochladen der Signaturen einrichten.');
|
||
console.log('Vorher in WordPress: Benutzer mit der Rolle "AeroMC Release" anlegen, dann in dessen Profil');
|
||
console.log('unter "Anwendungspasswörter" ein Passwort erzeugen (Name z. B. "AeroMC Build").\n');
|
||
const seite = (await frage(`Adresse der WordPress-Seite [${WP_STANDARD}]: `)) || WP_STANDARD;
|
||
if (!/^https:\/\//i.test(seite)) throw new Error('Bitte eine https://-Adresse angeben.');
|
||
const benutzer = await frage('WordPress-Benutzername: ');
|
||
const passwort = await frage('Anwendungspasswort (Eingabe bleibt unsichtbar): ', true);
|
||
if (!benutzer || !passwort) throw new Error('Benutzername und Passwort werden gebraucht.');
|
||
const zugang = { seite: seite.replace(/\/+$/, ''), benutzer, passwort };
|
||
const antwort = await pruefeZugang(zugang);
|
||
fs.mkdirSync(ORDNER, { recursive: true });
|
||
fs.writeFileSync(ZUGANG, JSON.stringify({ seite: zugang.seite, benutzer, passwort: verschluesseln(passwort) }, null, 2), { mode: 0o600 });
|
||
console.log(`\n✔ Zugang geprüft (angemeldet als ${antwort.benutzer}) und gespeichert: ${ZUGANG}`);
|
||
console.log(' Das Passwort ist mit Windows-DPAPI verschlüsselt.');
|
||
}
|
||
|
||
|
||
|
||
function sha256(datei) {
|
||
const h = crypto.createHash('sha256');
|
||
const fd = fs.openSync(datei, 'r');
|
||
const buf = Buffer.alloc(4 * 1024 * 1024);
|
||
let n;
|
||
while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) h.update(buf.subarray(0, n));
|
||
fs.closeSync(fd);
|
||
return h.digest('hex');
|
||
}
|
||
|
||
function signieren(art) {
|
||
const name = DATEIEN[art];
|
||
if (!name) throw new Error(`Unbekannte Art "${art}" – erwartet: setup, portable, schluessel, wordpress oder hochladen.`);
|
||
const datei = path.join(DIST, name);
|
||
if (!fs.existsSync(datei)) throw new Error(`${datei} fehlt – erst bauen.`);
|
||
if (!fs.existsSync(PRIVAT)) {
|
||
throw new Error(`Kein Release-Schlüssel unter ${PRIVAT}. Einmalig "node scripts/sign-release.js schluessel" ausführen.`);
|
||
}
|
||
const privat = crypto.createPrivateKey(fs.readFileSync(PRIVAT, 'utf8'));
|
||
|
||
const eingebaut = crypto.createPublicKey(fs.readFileSync(OEFFENTLICH, 'utf8')).export({ type: 'spki', format: 'der' });
|
||
const passend = crypto.createPublicKey(privat).export({ type: 'spki', format: 'der' });
|
||
if (!eingebaut.equals(passend)) {
|
||
throw new Error('Der private Schlüssel passt nicht zu src/update-schluessel.pem – Signatur wäre für den Launcher wertlos.');
|
||
}
|
||
|
||
const version = require(path.join(ROOT, 'package.json')).version;
|
||
const hash = sha256(datei);
|
||
const sig = {
|
||
format: 'AeroMC-Update/1',
|
||
datei: name,
|
||
version,
|
||
sha256: hash,
|
||
signatur: crypto.sign(null, signaturText(name, version, hash), privat).toString('base64'),
|
||
};
|
||
pruefeSignatur(sig, name, version, hash);
|
||
fs.writeFileSync(datei + '.sig', JSON.stringify(sig, null, 2) + '\n');
|
||
console.log(`\n» ${name} ${version} signiert`);
|
||
return sig;
|
||
}
|
||
|
||
async function hochladen(sig) {
|
||
const zugang = ladeZugang();
|
||
if (!zugang) {
|
||
throw new Error('Kein WordPress-Zugang eingerichtet – Signatur NICHT hochgeladen.\n'
|
||
+ ' Einmalig ausführen: node scripts/sign-release.js wordpress\n'
|
||
+ ` danach: node scripts/sign-release.js hochladen ${sig.datei === DATEIEN.setup ? 'setup' : 'portable'}`);
|
||
}
|
||
// Nie die Signatur einer schon veröffentlichten Datei überschreiben: Dann passte
|
||
// die EXE im Gitea-Release nicht mehr, und kein Launcher nähme das Update an.
|
||
const vorhanden = await holeSignatur(sig.datei, sig.version);
|
||
if (vorhanden && vorhanden.sha256 !== sig.sha256 && process.env.AEROMC_SIG_ERSETZEN !== '1') {
|
||
throw new Error(`Für ${sig.datei} ${sig.version} liegt schon eine andere Signatur auf WordPress – `
|
||
+ 'diese Version ist vermutlich bereits veröffentlicht. Bitte die Versionsnummer in package.json erhöhen.\n'
|
||
+ ' (Nur wenn die Version sicher NICHT veröffentlicht ist: AEROMC_SIG_ERSETZEN=1 setzen.)');
|
||
}
|
||
if (vorhanden && vorhanden.sha256 === sig.sha256) {
|
||
console.log('» Signatur liegt bereits unverändert auf WordPress.');
|
||
return;
|
||
}
|
||
const res = await fetch(`${zugang.seite}/wp-json/aeromc/v1/update-signatur`, {
|
||
method: 'POST', headers: kopfzeilen(zugang), body: JSON.stringify(sig),
|
||
});
|
||
let antwort = {};
|
||
try { antwort = await res.json(); } catch { /* leer */ }
|
||
if (!res.ok || !antwort.ok) {
|
||
throw new Error(`Hochladen fehlgeschlagen (HTTP ${res.status}): ${antwort.message || 'unbekannter Fehler'}`);
|
||
}
|
||
// Gegenprobe: liefert die öffentliche Schnittstelle genau das, was der Launcher braucht?
|
||
const zurueck = await holeSignatur(sig.datei, sig.version);
|
||
if (!zurueck || zurueck.signatur !== sig.signatur || zurueck.sha256 !== sig.sha256) {
|
||
throw new Error('Signatur wurde hochgeladen, die Seite liefert aber etwas anderes zurück (Cache?).');
|
||
}
|
||
console.log(`» Signatur auf ${zugang.seite} hochgeladen und geprüft${antwort.geprueft ? ' (vom Server gegengeprüft)' : ''} – ins Gitea-Release kommt nur die EXE.`);
|
||
}
|
||
|
||
async function main() {
|
||
const [art, zusatz] = process.argv.slice(2);
|
||
if (art === 'schluessel') return schluesselAnlegen();
|
||
if (art === 'wordpress') return wordpressEinrichten();
|
||
if (art === 'hochladen') {
|
||
const name = DATEIEN[zusatz];
|
||
if (!name) throw new Error('Erwartet: hochladen setup|portable');
|
||
const sigDatei = path.join(DIST, name + '.sig');
|
||
if (!fs.existsSync(sigDatei)) throw new Error(`${sigDatei} fehlt – erst bauen und signieren.`);
|
||
const sig = JSON.parse(fs.readFileSync(sigDatei, 'utf8'));
|
||
pruefeSignatur(sig, name, sig.version, sha256(path.join(DIST, name)));
|
||
return hochladen(sig);
|
||
}
|
||
return hochladen(signieren(art));
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error('\n✖ ' + err.message);
|
||
process.exit(1);
|
||
});
|