271 lines
8.8 KiB
TypeScript
271 lines
8.8 KiB
TypeScript
/**
|
|
* Wiederherstellung aus einer Sicherung.
|
|
*
|
|
* npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-00-00
|
|
* npm run wiederherstellen -- --quelle ... --wirklich
|
|
*
|
|
* Ohne `--wirklich` wird nur angezeigt, was geschehen würde. Das ist keine
|
|
* Bequemlichkeit: dieses Skript **überschreibt den gesamten Bestand**, und es
|
|
* gibt keinen Weg zurück außer einer weiteren Sicherung.
|
|
*
|
|
* Der eigentliche Zweck ist nicht der Notfall, sondern die Probe. Eine
|
|
* Sicherung, die nie zurückgespielt wurde, ist eine Vermutung — der Ordner
|
|
* kann leer sein, der Dump abgeschnitten, das Passwort falsch. Man erfährt es
|
|
* an dem Tag, an dem man es nicht erfahren will. Deshalb gehört dieser Lauf
|
|
* einmal durchgeführt und im Sicherungskonzept vermerkt.
|
|
*/
|
|
import "dotenv/config";
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync, readFileSync, readdirSync, statSync, createReadStream } from "node:fs";
|
|
import { join, relative, sep } from "node:path";
|
|
import { Client as MinioClient } from "minio";
|
|
import { zugangAus } from "./sicherung-zugang";
|
|
import { SPERREN, sperrenSql, verboteneRechte } from "./sicherung-sperren";
|
|
|
|
function log(nachricht: string): void {
|
|
console.info(`[wiederherstellen] ${nachricht}`);
|
|
}
|
|
|
|
function argument(name: string): string | undefined {
|
|
const stelle = process.argv.indexOf(`--${name}`);
|
|
return stelle >= 0 ? process.argv[stelle + 1] : undefined;
|
|
}
|
|
|
|
const wirklich = process.argv.includes("--wirklich");
|
|
|
|
/** Alle Dateien unterhalb eines Verzeichnisses, rekursiv. */
|
|
function alleDateien(wurzel: string): string[] {
|
|
if (!existsSync(wurzel)) return [];
|
|
|
|
const gefunden: string[] = [];
|
|
for (const eintrag of readdirSync(wurzel)) {
|
|
const pfad = join(wurzel, eintrag);
|
|
if (statSync(pfad).isDirectory()) gefunden.push(...alleDateien(pfad));
|
|
else gefunden.push(pfad);
|
|
}
|
|
return gefunden;
|
|
}
|
|
|
|
function datenbankZurueck(quelle: string): void {
|
|
const { benutzer, passwort, datenbank } = zugangAus(
|
|
process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL,
|
|
);
|
|
|
|
const dump = join(quelle, "datenbank.dump");
|
|
if (!existsSync(dump)) throw new Error(`In ${quelle} liegt keine datenbank.dump.`);
|
|
|
|
log("Spiele die Datenbank zurück ...");
|
|
|
|
/**
|
|
* `--clean --if-exists` räumt vorher auf.
|
|
*
|
|
* Ohne das Aufräumen schlüge jede `CREATE TABLE` fehl, und übrig bliebe ein
|
|
* Mischbestand aus alten und neuen Zeilen — das Schlimmste, was bei einer
|
|
* Wiederherstellung herauskommen kann, weil es auf den ersten Blick
|
|
* funktioniert.
|
|
*/
|
|
const lauf = spawnSync(
|
|
"docker",
|
|
[
|
|
"compose",
|
|
"exec",
|
|
"-T",
|
|
"postgres",
|
|
"pg_restore",
|
|
"--clean",
|
|
"--if-exists",
|
|
"--no-owner",
|
|
"--username",
|
|
benutzer,
|
|
"--dbname",
|
|
datenbank,
|
|
],
|
|
{
|
|
input: readFileSync(dump),
|
|
encoding: "buffer",
|
|
maxBuffer: 2_000_000_000,
|
|
shell: true,
|
|
env: { ...process.env, PGPASSWORD: passwort },
|
|
},
|
|
);
|
|
|
|
// pg_restore meldet auch dann einen Fehlercode, wenn nur einzelne DROP-
|
|
// Anweisungen ins Leere liefen. Das ist bei `--if-exists` normal, deshalb
|
|
// wird die Ausgabe angesehen statt nur der Rückgabewert.
|
|
const ausgabe = lauf.stderr?.toString() ?? "";
|
|
const echteFehler = ausgabe
|
|
.split("\n")
|
|
.filter((zeile) => zeile.includes("error:") && !zeile.includes("does not exist"));
|
|
|
|
if (echteFehler.length > 0) {
|
|
throw new Error(`pg_restore ist fehlgeschlagen:\n${echteFehler.join("\n")}`);
|
|
}
|
|
|
|
log("Datenbank zurückgespielt.");
|
|
}
|
|
|
|
async function dateienZurueck(quelle: string): Promise<number> {
|
|
const bucket = process.env.S3_BUCKET ?? "florian";
|
|
const ordner = join(quelle, "dateien");
|
|
|
|
const client = new MinioClient({
|
|
endPoint: process.env.S3_ENDPOINT ?? "localhost",
|
|
port: Number(process.env.S3_PORT ?? 9000),
|
|
useSSL: (process.env.S3_USE_SSL ?? "false") === "true",
|
|
accessKey: process.env.S3_ACCESS_KEY ?? "",
|
|
secretKey: process.env.S3_SECRET_KEY ?? "",
|
|
});
|
|
|
|
if (!(await client.bucketExists(bucket))) {
|
|
await client.makeBucket(bucket);
|
|
log(`Bucket "${bucket}" angelegt.`);
|
|
}
|
|
|
|
const dateien = alleDateien(ordner);
|
|
let hochgeladen = 0;
|
|
|
|
for (const pfad of dateien) {
|
|
// Der Objektschlüssel ist der Pfad unterhalb von "dateien/" — mit
|
|
// Schrägstrichen, auch unter Windows.
|
|
const schluessel = relative(ordner, pfad).split(sep).join("/");
|
|
|
|
await client.putObject(bucket, schluessel, createReadStream(pfad), statSync(pfad).size);
|
|
hochgeladen += 1;
|
|
|
|
if (hochgeladen % 50 === 0) log(` ${hochgeladen} Dateien ...`);
|
|
}
|
|
|
|
return hochgeladen;
|
|
}
|
|
|
|
/**
|
|
* Die Sperren wieder setzen — und danach nachsehen, ob es gewirkt hat.
|
|
*
|
|
* Warum das nötig ist, steht in `sicherung-sperren.ts`: die
|
|
* Vorgabeprivilegierung des Init-Skripts gibt jeder von `pg_restore` neu
|
|
* angelegten Tabelle wieder alle Rechte, und die `GRANT`-Zeilen aus dem Dump
|
|
* können nur hinzufügen, nie wegnehmen.
|
|
*
|
|
* Erst setzen, dann prüfen — und nicht nur prüfen. Ein Skript, das ein
|
|
* bekanntes Problem meldet, statt es zu beheben, verlagert die Arbeit auf den
|
|
* ungünstigsten Zeitpunkt: den Notfall, in dem jemand gerade eine Sicherung
|
|
* zurückspielt.
|
|
*/
|
|
async function sperrenSetzen(): Promise<void> {
|
|
const { PrismaClient } = await import("@prisma/client");
|
|
const prisma = new PrismaClient({
|
|
datasources: { db: { url: process.env.DIRECT_DATABASE_URL ?? "" } },
|
|
});
|
|
|
|
try {
|
|
const rolleDa = await prisma.$queryRawUnsafe<Array<{ da: boolean }>>(
|
|
"SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') AS da",
|
|
);
|
|
|
|
if (!rolleDa[0]?.da) {
|
|
log("Die Rolle florian_app gibt es nicht — Sperren übersprungen.");
|
|
return;
|
|
}
|
|
|
|
for (const anweisung of sperrenSql()) {
|
|
await prisma.$executeRawUnsafe(anweisung);
|
|
}
|
|
log(`Sperren gesetzt: ${SPERREN.length} Tabellen.`);
|
|
|
|
// Gegenprobe. Sie fragt die Datenbank, nicht das eigene Vorhaben ab.
|
|
const zeilen = await prisma.$queryRawUnsafe<
|
|
Array<{ table_name: string; privilege_type: string }>
|
|
>(
|
|
`SELECT table_name, privilege_type
|
|
FROM information_schema.role_table_grants
|
|
WHERE grantee = 'florian_app'`,
|
|
);
|
|
|
|
const vorhanden = new Set(zeilen.map((z) => `${z.table_name}:${z.privilege_type}`));
|
|
const befunde = verboteneRechte()
|
|
.filter(({ tabelle, recht }) => vorhanden.has(`${tabelle}:${recht}`))
|
|
.map(({ tabelle, recht }) => ` ${tabelle}: ${recht}`);
|
|
|
|
if (befunde.length > 0) {
|
|
log("");
|
|
log("ACHTUNG: Die Anwendungsrolle hat Rechte, die sie nicht haben darf:");
|
|
befunde.forEach((zeile) => log(zeile));
|
|
log("Die Unveraenderlichkeit von Protokollen ist damit aufgehoben.");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
log("Gegenprobe: keine Tabelle hat mehr Rechte als vorgesehen.");
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const quelle = argument("quelle");
|
|
if (!quelle) {
|
|
console.error("[wiederherstellen] --quelle <Verzeichnis> fehlt.");
|
|
process.exit(1);
|
|
}
|
|
|
|
const manifestPfad = join(quelle, "manifest.json");
|
|
if (!existsSync(manifestPfad)) {
|
|
console.error(
|
|
`[wiederherstellen] In ${quelle} liegt keine manifest.json. ` +
|
|
"Ist das wirklich ein Sicherungsverzeichnis?",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const manifest = JSON.parse(readFileSync(manifestPfad, "utf-8")) as {
|
|
erstelltAm: string;
|
|
version: string;
|
|
datenbank: { bytes: number };
|
|
dateien: { anzahl: number; bytes: number };
|
|
};
|
|
|
|
const alter = Math.round(
|
|
(Date.now() - new Date(manifest.erstelltAm).getTime()) / 1000 / 60 / 60 / 24,
|
|
);
|
|
|
|
log("");
|
|
log(`Sicherung vom ${manifest.erstelltAm.slice(0, 16).replace("T", " ")} (${alter} Tage alt)`);
|
|
log(`Version: ${manifest.version}`);
|
|
log(`Datenbank: ${(manifest.datenbank.bytes / 1024 / 1024).toFixed(1)} MB`);
|
|
log(`Dateien: ${manifest.dateien.anzahl} Stück`);
|
|
log("");
|
|
|
|
if (!wirklich) {
|
|
log("PROBELAUF — es wird nichts geändert.");
|
|
log("");
|
|
log("Zum wirklichen Zurückspielen dieselbe Zeile mit --wirklich am Ende.");
|
|
log("Achtung: dabei wird der gesamte vorhandene Bestand überschrieben.");
|
|
return;
|
|
}
|
|
|
|
if (alter > 30) {
|
|
log(`WARNUNG: Diese Sicherung ist ${alter} Tage alt.`);
|
|
}
|
|
|
|
log("Spiele zurück — der vorhandene Bestand wird überschrieben.");
|
|
datenbankZurueck(quelle);
|
|
|
|
const dateien = await dateienZurueck(quelle);
|
|
log(`Dateien: ${dateien} zurückgespielt.`);
|
|
|
|
await sperrenSetzen();
|
|
|
|
log("");
|
|
log("Fertig. Bitte jetzt von Hand prüfen:");
|
|
log(" 1. Anmelden — kommt jemand hinein?");
|
|
log(" 2. Ein Prüfprotokoll öffnen — ist das PDF da?");
|
|
log(" 3. Die Mitgliederzahl mit der Erwartung vergleichen.");
|
|
log("");
|
|
log("Erst danach gilt die Sicherung als geprüft.");
|
|
}
|
|
|
|
main().catch((fehler) => {
|
|
console.error("[wiederherstellen] Abbruch:", fehler);
|
|
process.exit(1);
|
|
});
|