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
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:
87
src/lib/backup/config.ts
Normal file
87
src/lib/backup/config.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { prisma } from '@/lib/db'
|
||||
|
||||
export type BackupDestinationType = 'sftp' | 'webdav'
|
||||
export type BackupSchedule = 'off' | 'daily' | 'weekly'
|
||||
|
||||
export interface SftpConfig {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
/** verschlüsselt (enc:v1:) */
|
||||
password: string
|
||||
remotePath: string
|
||||
}
|
||||
|
||||
export interface WebdavConfig {
|
||||
/** z.B. https://cloud.example.com/remote.php/dav/files/USER/ */
|
||||
url: string
|
||||
username: string
|
||||
/** verschlüsselt (enc:v1:) — bei Nextcloud besser ein App-Passwort */
|
||||
password: string
|
||||
remotePath: string
|
||||
}
|
||||
|
||||
export interface BackupConfig {
|
||||
enabled: boolean
|
||||
schedule: BackupSchedule
|
||||
retentionDays: number
|
||||
/** Passphrase zur Backup-Verschlüsselung, verschlüsselt (enc:v1:) */
|
||||
encryptPassphrase: string
|
||||
destinationType: BackupDestinationType
|
||||
sftp?: SftpConfig
|
||||
webdav?: WebdavConfig
|
||||
// Statusfelder (vom letzten Lauf)
|
||||
lastRun?: string | null
|
||||
lastStatus?: 'ok' | 'error' | null
|
||||
lastMessage?: string | null
|
||||
lastSizeBytes?: number | null
|
||||
}
|
||||
|
||||
const SETTING_KEY = 'backup_config'
|
||||
|
||||
export const DEFAULT_BACKUP_CONFIG: BackupConfig = {
|
||||
enabled: false,
|
||||
schedule: 'off',
|
||||
retentionDays: 30,
|
||||
encryptPassphrase: '',
|
||||
destinationType: 'sftp',
|
||||
sftp: { host: '', port: 22, username: '', password: '', remotePath: '/lageplan-backups' },
|
||||
webdav: { url: '', username: '', password: '', remotePath: '/Lageplan-Backups' },
|
||||
lastRun: null, lastStatus: null, lastMessage: null, lastSizeBytes: null,
|
||||
}
|
||||
|
||||
export async function loadBackupConfig(): Promise<BackupConfig | null> {
|
||||
try {
|
||||
const row = await (prisma as any).systemSetting.findUnique({ where: { key: SETTING_KEY } })
|
||||
if (!row?.value) return null
|
||||
return { ...DEFAULT_BACKUP_CONFIG, ...JSON.parse(row.value) }
|
||||
} catch (e) {
|
||||
console.error('[backup] loadBackupConfig:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBackupConfig(cfg: BackupConfig): Promise<void> {
|
||||
await (prisma as any).systemSetting.upsert({
|
||||
where: { key: SETTING_KEY },
|
||||
update: { value: JSON.stringify(cfg) },
|
||||
create: { key: SETTING_KEY, value: JSON.stringify(cfg), isSecret: true, category: 'backup' },
|
||||
})
|
||||
}
|
||||
|
||||
/** Konfig für die API maskieren: Geheimnisse durch Boolean-Flags ersetzen. */
|
||||
export function maskBackupConfig(cfg: BackupConfig) {
|
||||
return {
|
||||
enabled: cfg.enabled,
|
||||
schedule: cfg.schedule,
|
||||
retentionDays: cfg.retentionDays,
|
||||
hasPassphrase: !!cfg.encryptPassphrase,
|
||||
destinationType: cfg.destinationType,
|
||||
sftp: cfg.sftp ? { host: cfg.sftp.host, port: cfg.sftp.port, username: cfg.sftp.username, remotePath: cfg.sftp.remotePath, hasPassword: !!cfg.sftp.password } : undefined,
|
||||
webdav: cfg.webdav ? { url: cfg.webdav.url, username: cfg.webdav.username, remotePath: cfg.webdav.remotePath, hasPassword: !!cfg.webdav.password } : undefined,
|
||||
lastRun: cfg.lastRun ?? null,
|
||||
lastStatus: cfg.lastStatus ?? null,
|
||||
lastMessage: cfg.lastMessage ?? null,
|
||||
lastSizeBytes: cfg.lastSizeBytes ?? null,
|
||||
}
|
||||
}
|
||||
101
src/lib/backup/destinations.ts
Normal file
101
src/lib/backup/destinations.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createReadStream, readFileSync } from 'fs'
|
||||
import type { BackupDestinationType } from './config'
|
||||
|
||||
// Entschlüsselte (Klartext-)Zugangsdaten — werden vom Aufrufer aus der Konfig entschlüsselt.
|
||||
export interface SftpPlain { host: string; port: number; username: string; password: string; remotePath: string }
|
||||
export interface WebdavPlain { url: string; username: string; password: string; remotePath: string }
|
||||
export type PlainDest = SftpPlain | WebdavPlain
|
||||
|
||||
export interface RemoteFile { name: string; size?: number; modified?: number }
|
||||
|
||||
function joinRemote(dir: string, name: string): string {
|
||||
return `${dir.replace(/\/+$/, '')}/${name}`
|
||||
}
|
||||
|
||||
// ─── SFTP ───────────────────────────────────────────────
|
||||
async function withSftp<T>(c: SftpPlain, fn: (sftp: any) => Promise<T>): Promise<T> {
|
||||
const SftpClient = (await import('ssh2-sftp-client')).default
|
||||
const sftp = new SftpClient()
|
||||
await sftp.connect({ host: c.host, port: c.port || 22, username: c.username, password: c.password, readyTimeout: 15000 })
|
||||
try {
|
||||
return await fn(sftp)
|
||||
} finally {
|
||||
try { await sftp.end() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WebDAV (Nextcloud) ─────────────────────────────────
|
||||
async function getWebdav(c: WebdavPlain) {
|
||||
const { createClient } = await import('webdav')
|
||||
return createClient(c.url, { username: c.username, password: c.password })
|
||||
}
|
||||
|
||||
// ─── Öffentliche, typ-übergreifende API ─────────────────
|
||||
|
||||
export async function testDestination(type: BackupDestinationType, dest: PlainDest): Promise<{ ok: boolean; message: string }> {
|
||||
try {
|
||||
const marker = Buffer.from(`lageplan-backup-test ${new Date().toISOString()}`)
|
||||
if (type === 'sftp') {
|
||||
const c = dest as SftpPlain
|
||||
await withSftp(c, async (sftp) => {
|
||||
if (!(await sftp.exists(c.remotePath))) await sftp.mkdir(c.remotePath, true)
|
||||
const p = joinRemote(c.remotePath, '.lageplan-test')
|
||||
await sftp.put(marker, p)
|
||||
await sftp.delete(p)
|
||||
})
|
||||
return { ok: true, message: 'SFTP-Verbindung und Schreibzugriff ok.' }
|
||||
}
|
||||
const c = dest as WebdavPlain
|
||||
const dav = await getWebdav(c)
|
||||
if (!(await dav.exists(c.remotePath))) await dav.createDirectory(c.remotePath, { recursive: true } as any)
|
||||
const p = joinRemote(c.remotePath, '.lageplan-test')
|
||||
await dav.putFileContents(p, marker)
|
||||
await dav.deleteFile(p)
|
||||
return { ok: true, message: 'WebDAV-Verbindung und Schreibzugriff ok.' }
|
||||
} catch (e: any) {
|
||||
return { ok: false, message: e?.message || 'Verbindung fehlgeschlagen.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadBackup(type: BackupDestinationType, dest: PlainDest, localPath: string, remoteName: string): Promise<void> {
|
||||
if (type === 'sftp') {
|
||||
const c = dest as SftpPlain
|
||||
await withSftp(c, async (sftp) => {
|
||||
if (!(await sftp.exists(c.remotePath))) await sftp.mkdir(c.remotePath, true)
|
||||
await sftp.put(createReadStream(localPath), joinRemote(c.remotePath, remoteName))
|
||||
})
|
||||
return
|
||||
}
|
||||
const c = dest as WebdavPlain
|
||||
const dav = await getWebdav(c)
|
||||
if (!(await dav.exists(c.remotePath))) await dav.createDirectory(c.remotePath, { recursive: true } as any)
|
||||
// Für WebDAV als Buffer hochladen (Backups einer kleinen Feuerwehr sind moderat gross).
|
||||
await dav.putFileContents(joinRemote(c.remotePath, remoteName), readFileSync(localPath), { overwrite: true } as any)
|
||||
}
|
||||
|
||||
export async function listBackups(type: BackupDestinationType, dest: PlainDest): Promise<RemoteFile[]> {
|
||||
if (type === 'sftp') {
|
||||
const c = dest as SftpPlain
|
||||
return withSftp(c, async (sftp) => {
|
||||
if (!(await sftp.exists(c.remotePath))) return []
|
||||
const list = await sftp.list(c.remotePath)
|
||||
return list.filter((f: any) => f.type === '-').map((f: any) => ({ name: f.name, size: f.size, modified: f.modifyTime }))
|
||||
})
|
||||
}
|
||||
const c = dest as WebdavPlain
|
||||
const dav = await getWebdav(c)
|
||||
if (!(await dav.exists(c.remotePath))) return []
|
||||
const items = (await dav.getDirectoryContents(c.remotePath)) as any[]
|
||||
return items.filter(i => i.type === 'file').map(i => ({ name: i.basename, size: i.size, modified: i.lastmod ? Date.parse(i.lastmod) : undefined }))
|
||||
}
|
||||
|
||||
export async function deleteBackup(type: BackupDestinationType, dest: PlainDest, name: string): Promise<void> {
|
||||
if (type === 'sftp') {
|
||||
const c = dest as SftpPlain
|
||||
await withSftp(c, async (sftp) => { await sftp.delete(joinRemote(c.remotePath, name)) })
|
||||
return
|
||||
}
|
||||
const c = dest as WebdavPlain
|
||||
const dav = await getWebdav(c)
|
||||
await dav.deleteFile(joinRemote(c.remotePath, name))
|
||||
}
|
||||
163
src/lib/backup/engine.ts
Normal file
163
src/lib/backup/engine.ts
Normal 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
|
||||
}
|
||||
19
src/lib/backup/secrets.ts
Normal file
19
src/lib/backup/secrets.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { decryptSecret } from '@/lib/crypto-secret'
|
||||
import type { BackupConfig } from './config'
|
||||
import type { PlainDest, SftpPlain, WebdavPlain } from './destinations'
|
||||
|
||||
/** Entschlüsselt die Zugangsdaten des aktiven Ziels aus der Konfig. */
|
||||
export function plainDestFromConfig(cfg: BackupConfig): PlainDest {
|
||||
if (cfg.destinationType === 'sftp') {
|
||||
const s = cfg.sftp
|
||||
if (!s) throw new Error('SFTP nicht konfiguriert')
|
||||
return { host: s.host, port: s.port, username: s.username, password: decryptSecret(s.password), remotePath: s.remotePath } as SftpPlain
|
||||
}
|
||||
const w = cfg.webdav
|
||||
if (!w) throw new Error('WebDAV nicht konfiguriert')
|
||||
return { url: w.url, username: w.username, password: decryptSecret(w.password), remotePath: w.remotePath } as WebdavPlain
|
||||
}
|
||||
|
||||
export function getPassphrase(cfg: BackupConfig): string {
|
||||
return decryptSecret(cfg.encryptPassphrase || '')
|
||||
}
|
||||
Reference in New Issue
Block a user