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:
Kundencenter 2026-09-29 10:44:24 +02:00
parent acf8751f9f
commit 8cf8404440

View file

@ -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`);