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

51
scripts/decrypt-backup.js Normal file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env node
/**
* Entschlüsselt ein Lageplan-Backup (.tar.gz.enc) → .tar.gz
*
* Verwendung:
* node scripts/decrypt-backup.js <input.tar.gz.enc> <output.tar.gz> [passphrase]
* (ohne Passphrase-Argument wird sie interaktiv abgefragt bzw. aus BACKUP_PASSPHRASE gelesen)
*
* Danach:
* tar xzf output.tar.gz → enthält database.dump + files/<uploads>
* pg_restore --clean --if-exists -d <DB> database.dump
* files/ zurück in MinIO spielen (siehe docs/BACKUP.md)
*
* Format der .enc-Datei: [salt(16)][iv(12)][ciphertext][tag(16)], AES-256-GCM, Key = scrypt(passphrase, salt).
*/
const fs = require('fs')
const crypto = require('crypto')
const [inPath, outPath] = process.argv.slice(2)
let passphrase = process.argv[4] || process.env.BACKUP_PASSPHRASE
if (!inPath || !outPath) {
console.error('Verwendung: node decrypt-backup.js <input.tar.gz.enc> <output.tar.gz> [passphrase]')
process.exit(1)
}
if (!passphrase) {
console.error('Passphrase fehlt. Als 3. Argument oder via BACKUP_PASSPHRASE übergeben.')
process.exit(1)
}
const size = fs.statSync(inPath).size
if (size < 44) { console.error('Datei zu klein / kein gültiges Backup.'); process.exit(1) }
const fd = fs.openSync(inPath, 'r')
const head = Buffer.alloc(28)
fs.readSync(fd, head, 0, 28, 0)
const tag = Buffer.alloc(16)
fs.readSync(fd, tag, 0, 16, size - 16)
fs.closeSync(fd)
const salt = head.subarray(0, 16)
const iv = head.subarray(16, 28)
const key = crypto.scryptSync(passphrase, salt, 32)
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
const input = fs.createReadStream(inPath, { start: 28, end: size - 17 })
const output = fs.createWriteStream(outPath)
input.pipe(decipher).pipe(output)
output.on('finish', () => console.log('OK →', outPath))
decipher.on('error', (e) => { console.error('Entschlüsselung fehlgeschlagen (falsche Passphrase?):', e.message); process.exit(1) })