feat(backup): GUI-konfigurierbares, verschlüsseltes Off-Site-Backup (SFTP/Nextcloud) (v1.9.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 54m48s

Admin → Backup: Ziel (SFTP oder Nextcloud/WebDAV) konfigurieren, Verbindung testen, Jetzt sichern,
Zeitplan (aus/täglich/wöchentlich) + Aufbewahrung. Best Practice: verschlüsselt + off-site.

- Verschlüsselung: Backup als tar.gz (database.dump + MinIO-Dateien) → AES-256-GCM mit Passphrase.
  Zugangsdaten + Passphrase verschlüsselt in DB (src/lib/crypto-secret.ts, Schlüssel aus Server-Secret)
- Engine (src/lib/backup): pg_dump + archiver-Stream aus MinIO + Stream-Verschlüsselung + Upload + Prune
- Ziel-Adapter: SFTP (ssh2-sftp-client) + WebDAV (webdav), je test/upload/list/delete
- API (SERVER_ADMIN): /api/admin/backup/{config,test,run}; öffentlich per CRON_SECRET: /api/cron/backup
- Scheduler in server-custom.js (stündliche Fälligkeitsprüfung)
- Dockerfile: postgresql16-client (pg_dump) + runtime-Libs; next.config serverExternalPackages
- scripts/decrypt-backup.js + docs/BACKUP.md (Restore-Anleitung), .env.example (CRON_SECRET, BACKUP_ENC_KEY)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pepe Ziberi
2026-07-23 23:33:48 +02:00
parent 15dd78c848
commit 28d346d0f5
20 changed files with 1872 additions and 8 deletions

163
src/lib/backup/engine.ts Normal file
View File

@@ -0,0 +1,163 @@
import { spawn } from 'child_process'
import { createWriteStream, createReadStream, promises as fsp } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { randomBytes, scryptSync, createCipheriv } from 'crypto'
// archiver nutzt `export =` (CommonJS); require umgeht die Default-Import-Inkompatibilität.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const archiver = require('archiver') as (format: string, opts?: any) => any
import { minioClient, BUCKET } from '@/lib/minio'
import { loadBackupConfig, saveBackupConfig, type BackupConfig } from './config'
import { plainDestFromConfig, getPassphrase } from './secrets'
import { uploadBackup, listBackups, deleteBackup } from './destinations'
const NAME_PREFIX = 'lageplan_'
const NAME_RE = /^lageplan_(\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})\.tar\.gz\.enc$/
function tsName(d = new Date()): string {
// 2026-07-23-22-30-00 (UTC, sortierbar)
return d.toISOString().replace('T', '-').replace(/:/g, '-').slice(0, 19)
}
function fmtSize(n: number): string {
if (n > 1e9) return (n / 1e9).toFixed(1) + ' GB'
if (n > 1e6) return (n / 1e6).toFixed(1) + ' MB'
return (n / 1e3).toFixed(0) + ' KB'
}
/** PostgreSQL-Dump (custom/komprimiert) via pg_dump. */
function pgDump(dumpPath: string): Promise<void> {
const url = process.env.DATABASE_URL
if (!url) return Promise.reject(new Error('DATABASE_URL fehlt'))
return new Promise((resolve, reject) => {
const p = spawn('pg_dump', [url, '-Fc', '-f', dumpPath], { stdio: ['ignore', 'ignore', 'pipe'] })
let err = ''
p.stderr.on('data', d => { err += d.toString() })
p.on('error', (e) => reject(new Error(`pg_dump nicht ausführbar: ${e.message} (postgresql-client im Image?)`)))
p.on('close', code => code === 0 ? resolve() : reject(new Error('pg_dump: ' + (err || `Exit ${code}`))))
})
}
function listObjectNames(): Promise<string[]> {
return new Promise((resolve, reject) => {
const out: string[] = []
const s = minioClient.listObjects(BUCKET, '', true)
s.on('data', (o: any) => { if (o.name) out.push(o.name) })
s.on('error', reject)
s.on('end', () => resolve(out))
})
}
/** tar.gz mit database.dump + files/<minio-objekte>. */
async function buildArchive(dumpPath: string, tarPath: string): Promise<void> {
const names = await listObjectNames()
const output = createWriteStream(tarPath)
const archive = archiver('tar', { gzip: true, gzipOptions: { level: 6 } })
const done = new Promise<void>((resolve, reject) => {
output.on('close', () => resolve())
output.on('error', reject)
archive.on('error', reject)
})
archive.pipe(output)
archive.file(dumpPath, { name: 'database.dump' })
for (const name of names) {
const st = await minioClient.getObject(BUCKET, name)
archive.append(st as any, { name: 'files/' + name })
}
await archive.finalize()
await done
}
/** AES-256-GCM Stream-Verschlüsselung mit Passphrase. Format: [salt16][iv12][ciphertext][tag16]. */
function encryptFile(inPath: string, outPath: string, passphrase: string): Promise<void> {
return new Promise((resolve, reject) => {
const salt = randomBytes(16)
const iv = randomBytes(12)
const key = scryptSync(passphrase, salt, 32)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const out = createWriteStream(outPath)
out.on('error', reject)
out.on('close', () => resolve())
out.write(salt); out.write(iv)
const input = createReadStream(inPath)
input.on('error', reject)
cipher.on('error', reject)
cipher.on('data', (c) => out.write(c))
cipher.on('end', () => { out.write(cipher.getAuthTag()); out.end() })
input.pipe(cipher)
})
}
/** Entfernt Remote-Backups, die älter als retentionDays sind (Zeitstempel aus dem Dateinamen). */
async function prune(cfg: BackupConfig): Promise<void> {
try {
const dest = plainDestFromConfig(cfg)
const files = await listBackups(cfg.destinationType, dest)
const cutoff = Date.now() - cfg.retentionDays * 86400_000
for (const f of files) {
const m = f.name.match(NAME_RE)
if (!m) continue
// 2026-07-23-22-30-00 → ISO
const iso = m[1].replace(/^(\d{4}-\d{2}-\d{2})-(\d{2})-(\d{2})-(\d{2})$/, '$1T$2:$3:$4Z')
const t = Date.parse(iso)
if (!isNaN(t) && t < cutoff) {
await deleteBackup(cfg.destinationType, dest, f.name)
}
}
} catch (e) {
console.warn('[backup] prune:', e)
}
}
/**
* Führt ein vollständiges Backup aus: DB-Dump + Dateien → tar.gz → verschlüsselt → Upload → prune.
* Läuft unabhängig vom Zeitplan (auch für „Jetzt sichern"), solange Ziel + Passphrase gesetzt sind.
*/
export async function runBackup(): Promise<{ ok: boolean; message: string }> {
const cfg = await loadBackupConfig()
if (!cfg) throw new Error('Backup nicht konfiguriert.')
const pass = getPassphrase(cfg)
if (!pass) throw new Error('Backup-Passphrase fehlt.')
const dest = plainDestFromConfig(cfg) // wirft, wenn Ziel nicht konfiguriert
const ts = tsName()
const tmp = await fsp.mkdtemp(join(tmpdir(), 'lpbak-'))
const dumpPath = join(tmp, 'db.dump')
const tarPath = join(tmp, `${NAME_PREFIX}${ts}.tar.gz`)
const encPath = `${tarPath}.enc`
const remoteName = `${NAME_PREFIX}${ts}.tar.gz.enc`
try {
await pgDump(dumpPath)
await buildArchive(dumpPath, tarPath)
await encryptFile(tarPath, encPath, pass)
await uploadBackup(cfg.destinationType, dest, encPath, remoteName)
const size = (await fsp.stat(encPath)).size
await prune(cfg)
cfg.lastRun = new Date().toISOString()
cfg.lastStatus = 'ok'
cfg.lastMessage = `Gesichert: ${remoteName} (${fmtSize(size)})`
cfg.lastSizeBytes = size
await saveBackupConfig(cfg)
return { ok: true, message: cfg.lastMessage }
} catch (e: any) {
cfg.lastRun = new Date().toISOString()
cfg.lastStatus = 'error'
cfg.lastMessage = e?.message || 'Unbekannter Fehler'
await saveBackupConfig(cfg)
throw e
} finally {
await fsp.rm(tmp, { recursive: true, force: true }).catch(() => {})
}
}
/** Prüft, ob laut Zeitplan ein Backup fällig ist (für den Scheduler). */
export function isBackupDue(cfg: BackupConfig, now = Date.now()): boolean {
if (!cfg.enabled || cfg.schedule === 'off') return false
const intervalMs = cfg.schedule === 'weekly' ? 7 * 86400_000 : 86400_000
if (!cfg.lastRun) return true
const last = Date.parse(cfg.lastRun)
if (isNaN(last)) return true
// Bei Fehlversuchen nicht dauernd neu starten: erst nach dem Intervall erneut.
return now - last >= intervalMs
}