fix(backup): Ticket-Anhänge in Sicherung und Wiederherstellungstest einschließen
Vom Nacht-Agenten gefundene Lücke (P1), gegen den echten Code verifiziert: das Backup-Archiv enthielt nur db.sql/config/manifest.json, nicht das Anhänge-Verzeichnis (STORE_ROOT, /var/lib/kundencenter/ticket-attachments). Bei einem echten Datenverlust wären alle Ticket-Anhänge unwiederbringlich verloren gewesen, obwohl die Datenbank-Referenzen darauf erhalten blieben. - runBackup: Anhänge-Verzeichnis wird mit in archive.tar.gz gepackt (eigener -C-Abschnitt, da es außerhalb des Arbeitsverzeichnisses liegt), Datei-/ Byte-Anzahl landet zur Kontrolle im manifest.json. - runRestoreTest: prüft nach dem Entpacken, ob Datei- und Byte-Anzahl der Anhänge mit dem Manifest übereinstimmen (wie die bestehenden Zeilenzahl-/ Migrations-/Audit-Kette-Prüfungen). Ältere Sicherungen ohne dieses Feld werden übersprungen statt fälschlich als fehlerhaft gemeldet. Gegen eine echte Sicherung+Wiederherstellungstest verifiziert (zwei Testdateien im Anhänge-Verzeichnis angelegt, Backup gefahren, Restore-Test lief grün mit anhaengeDateien: 2). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
acf8751f9f
commit
8cf8404440
1 changed files with 29 additions and 3 deletions
|
|
@ -3,12 +3,13 @@ import { createReadStream, createWriteStream, existsSync } from 'node:fs';
|
||||||
import { chmod, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile, copyFile } from 'node:fs/promises';
|
import { chmod, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile, copyFile } from 'node:fs/promises';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join, basename } from 'node:path';
|
import { join, basename, dirname } from 'node:path';
|
||||||
import mysql from 'mysql2/promise';
|
import mysql from 'mysql2/promise';
|
||||||
import { config } from '../core/config.js';
|
import { config } from '../core/config.js';
|
||||||
import { query, one } from '../core/db.js';
|
import { query, one } from '../core/db.js';
|
||||||
import { audit, verifyAuditChain } from '../core/audit.js';
|
import { audit, verifyAuditChain } from '../core/audit.js';
|
||||||
import { enqueue } from '../core/jobs.js';
|
import { enqueue } from '../core/jobs.js';
|
||||||
|
import { STORE_ROOT as ATTACHMENT_DIR } from '../modules/tickets/files.js';
|
||||||
import { fileName, parseName, selectDeletions, type Keep } from './retention.js';
|
import { fileName, parseName, selectDeletions, type Keep } from './retention.js';
|
||||||
import { getBackupPassword } from './settings.js';
|
import { getBackupPassword } from './settings.js';
|
||||||
import { buildRemote, loadTargets, friendly, type Remote } from './targets.js';
|
import { buildRemote, loadTargets, friendly, type Remote } from './targets.js';
|
||||||
|
|
@ -72,6 +73,21 @@ async function tableCounts(q: (sql: string) => Promise<any[]>): Promise<Record<s
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Zählt Dateien/Bytes rekursiv (für Ticket-Anhänge). Fehlt das Verzeichnis, gilt das als leer, nicht als Fehler. */
|
||||||
|
async function dirStats(dir: string): Promise<{ files: number; bytes: number }> {
|
||||||
|
if (!existsSync(dir)) return { files: 0, bytes: 0 };
|
||||||
|
let files = 0; let bytes = 0;
|
||||||
|
const walk = async (d: string) => {
|
||||||
|
for (const e of await readdir(d, { withFileTypes: true })) {
|
||||||
|
const p = join(d, e.name);
|
||||||
|
if (e.isDirectory()) await walk(p);
|
||||||
|
else if (e.isFile()) { files += 1; bytes += (await stat(p)).size; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await walk(dir);
|
||||||
|
return { files, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
/** Erstellt ein verschlüsseltes Backup (DB-Dump + Konfiguration/Schlüssel), lädt es zu allen Zielen hoch und räumt nach Aufbewahrungsregeln auf. */
|
/** Erstellt ein verschlüsseltes Backup (DB-Dump + Konfiguration/Schlüssel), lädt es zu allen Zielen hoch und räumt nach Aufbewahrungsregeln auf. */
|
||||||
export async function runBackup(c: BackupConfig, now = new Date()): Promise<NonNullable<Status['lastRun']>> {
|
export async function runBackup(c: BackupConfig, now = new Date()): Promise<NonNullable<Status['lastRun']>> {
|
||||||
const t0 = Date.now(); const targets: { name: string; ok: boolean; error?: string }[] = []; const dests: { label: string; remote: string; env: Record<string, string> }[] = []; const built: Remote[] = [];
|
const t0 = Date.now(); const targets: { name: string; ok: boolean; error?: string }[] = []; const dests: { label: string; remote: string; env: Record<string, string> }[] = []; const built: Remote[] = [];
|
||||||
|
|
@ -87,8 +103,12 @@ export async function runBackup(c: BackupConfig, now = new Date()): Promise<NonN
|
||||||
for (const f of ['app.env', 'db.env', 'discord.env']) if (existsSync(join(c.envDir, f))) await copyFile(join(c.envDir, f), join(work, 'config', f));
|
for (const f of ['app.env', 'db.env', 'discord.env']) if (existsSync(join(c.envDir, f))) await copyFile(join(c.envDir, f), join(work, 'config', f));
|
||||||
const counts = await tableCounts((sql) => query(sql));
|
const counts = await tableCounts((sql) => query(sql));
|
||||||
const migrations = (await query('SELECT name FROM schema_migrations ORDER BY name')).map((r) => r.name as string);
|
const migrations = (await query('SELECT name FROM schema_migrations ORDER BY name')).map((r) => r.name as string);
|
||||||
await writeFile(join(work, 'manifest.json'), JSON.stringify({ version: 1, createdAt: now.toISOString(), database: config.db.database, counts, migrations, dumpSha256: await sha256(join(work, 'db.sql')) }, null, 2));
|
const attachments = await dirStats(ATTACHMENT_DIR);
|
||||||
await run('tar', ['-czf', join(work, 'archive.tar.gz'), '-C', work, 'db.sql', 'config', 'manifest.json']);
|
await writeFile(join(work, 'manifest.json'), JSON.stringify({ version: 1, createdAt: now.toISOString(), database: config.db.database, counts, migrations, attachments, dumpSha256: await sha256(join(work, 'db.sql')) }, null, 2));
|
||||||
|
const tarArgs = ['-czf', join(work, 'archive.tar.gz'), '-C', work, 'db.sql', 'config', 'manifest.json'];
|
||||||
|
// Ticket-Anhänge liegen außerhalb von work (STORE_ROOT); eigener -C-Abschnitt bettet sie unter ihrem Basisnamen mit ein.
|
||||||
|
if (attachments.files > 0) tarArgs.push('-C', dirname(ATTACHMENT_DIR), basename(ATTACHMENT_DIR));
|
||||||
|
await run('tar', tarArgs);
|
||||||
const out = join(c.dir, name);
|
const out = join(c.dir, name);
|
||||||
if (pw) await gpgEncrypt(work, join(work, 'archive.tar.gz'), out, pw.password);
|
if (pw) await gpgEncrypt(work, join(work, 'archive.tar.gz'), out, pw.password);
|
||||||
else if (c.recipient) await run('age', ['-r', c.recipient, '-o', out, join(work, 'archive.tar.gz')]);
|
else if (c.recipient) await run('age', ['-r', c.recipient, '-o', out, join(work, 'archive.tar.gz')]);
|
||||||
|
|
@ -172,6 +192,12 @@ export async function runRestoreTest(c: BackupConfig, file?: string): Promise<No
|
||||||
await run('tar', ['-xzf', join(work, 'archive.tar.gz'), '-C', work]);
|
await run('tar', ['-xzf', join(work, 'archive.tar.gz'), '-C', work]);
|
||||||
const manifest = JSON.parse(await readFile(join(work, 'manifest.json'), 'utf8')); checks.dumpPruefsumme = (await sha256(join(work, 'db.sql'))) === manifest.dumpSha256;
|
const manifest = JSON.parse(await readFile(join(work, 'manifest.json'), 'utf8')); checks.dumpPruefsumme = (await sha256(join(work, 'db.sql'))) === manifest.dumpSha256;
|
||||||
if (!checks.dumpPruefsumme) throw new Error('Prüfsumme des Datenbank-Dumps stimmt nicht');
|
if (!checks.dumpPruefsumme) throw new Error('Prüfsumme des Datenbank-Dumps stimmt nicht');
|
||||||
|
if (manifest.attachments) {
|
||||||
|
const extracted = await dirStats(join(work, basename(ATTACHMENT_DIR)));
|
||||||
|
checks.anhaenge = extracted.files === manifest.attachments.files && extracted.bytes === manifest.attachments.bytes;
|
||||||
|
checks.anhaengeDateien = extracted.files;
|
||||||
|
if (!checks.anhaenge) throw new Error(`Ticket-Anhänge in der Sicherung unvollständig: ${extracted.files}/${manifest.attachments.files} Dateien`);
|
||||||
|
}
|
||||||
const admin = await mysql.createConnection({ host: config.db.host, port: config.db.port, user: config.db.user, password: config.db.password });
|
const admin = await mysql.createConnection({ host: config.db.host, port: config.db.port, user: config.db.user, password: config.db.password });
|
||||||
try {
|
try {
|
||||||
await admin.query(`DROP DATABASE IF EXISTS \`${scratch}\``); await admin.query(`CREATE DATABASE \`${scratch}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
await admin.query(`DROP DATABASE IF EXISTS \`${scratch}\``); await admin.query(`CREATE DATABASE \`${scratch}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue