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

View File

@@ -0,0 +1,160 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { HardDriveDownload, Loader2, ShieldCheck, PlugZap, Save } from 'lucide-react'
interface MaskedCfg {
enabled: boolean
schedule: 'off' | 'daily' | 'weekly'
retentionDays: number
hasPassphrase: boolean
destinationType: 'sftp' | 'webdav'
sftp?: { host: string; port: number; username: string; remotePath: string; hasPassword: boolean }
webdav?: { url: string; username: string; remotePath: string; hasPassword: boolean }
lastRun?: string | null
lastStatus?: 'ok' | 'error' | null
lastMessage?: string | null
}
export function BackupTab() {
const { toast } = useToast()
const [cfg, setCfg] = useState<MaskedCfg | null>(null)
const [busy, setBusy] = useState<'' | 'save' | 'test' | 'run'>('')
// Neue Geheimnisse (nur senden, wenn ausgefüllt)
const [passphrase, setPassphrase] = useState('')
const [sftpPw, setSftpPw] = useState('')
const [webdavPw, setWebdavPw] = useState('')
const load = useCallback(async () => {
const r = await fetch('/api/admin/backup/config')
if (r.ok) setCfg(await r.json())
}, [])
useEffect(() => { load() }, [load])
if (!cfg) return <div className="py-10 flex justify-center"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
const patch = (p: Partial<MaskedCfg>) => setCfg(c => c ? { ...c, ...p } : c)
const patchSftp = (p: any) => setCfg(c => c ? { ...c, sftp: { ...(c.sftp || { host: '', port: 22, username: '', remotePath: '/lageplan-backups', hasPassword: false }), ...p } } : c)
const patchWebdav = (p: any) => setCfg(c => c ? { ...c, webdav: { ...(c.webdav || { url: '', username: '', remotePath: '/Lageplan-Backups', hasPassword: false }), ...p } } : c)
const save = async () => {
setBusy('save')
try {
const body: any = {
enabled: cfg.enabled, schedule: cfg.schedule, retentionDays: cfg.retentionDays,
destinationType: cfg.destinationType,
}
if (passphrase) body.passphrase = passphrase
if (cfg.destinationType === 'sftp' && cfg.sftp) body.sftp = { ...cfg.sftp, password: sftpPw || undefined }
if (cfg.destinationType === 'webdav' && cfg.webdav) body.webdav = { ...cfg.webdav, password: webdavPw || undefined }
const r = await fetch('/api/admin/backup/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (r.ok) { setCfg(await r.json()); setPassphrase(''); setSftpPw(''); setWebdavPw(''); toast({ title: 'Gespeichert' }) }
else { const d = await r.json(); toast({ title: 'Fehler', description: d.error, variant: 'destructive' }) }
} finally { setBusy('') }
}
const test = async () => {
setBusy('test')
try {
const r = await fetch('/api/admin/backup/test', { method: 'POST' })
const d = await r.json()
toast({ title: d.ok ? 'Verbindung ok' : 'Verbindung fehlgeschlagen', description: d.message, variant: d.ok ? undefined : 'destructive' })
} finally { setBusy('') }
}
const runNow = async () => {
if (!confirm('Jetzt ein Backup erstellen und zum Ziel hochladen?')) return
setBusy('run')
try {
const r = await fetch('/api/admin/backup/run', { method: 'POST' })
const d = await r.json()
toast({ title: d.ok ? 'Backup erstellt' : 'Backup fehlgeschlagen', description: d.message, variant: d.ok ? undefined : 'destructive' })
load()
} finally { setBusy('') }
}
return (
<div className="space-y-6 max-w-2xl">
<div>
<h3 className="text-lg font-semibold flex items-center gap-2"><HardDriveDownload className="w-5 h-5 text-primary" /> Backup (verschlüsselt, extern)</h3>
<p className="text-sm text-muted-foreground mt-1">
Sichert Datenbank + hochgeladene Dateien verschlüsselt auf ein externes Ziel (SFTP oder Nextcloud/WebDAV).
Best Practice: zusätzlich zum lokalen Backup, an einen anderen Ort.
</p>
</div>
{/* Status */}
{cfg.lastRun && (
<div className={`rounded-lg border p-3 text-sm ${cfg.lastStatus === 'ok' ? 'border-green-300 bg-green-50 dark:bg-green-950/30 dark:border-green-800' : 'border-red-300 bg-red-50 dark:bg-red-950/30 dark:border-red-800'}`}>
<strong>Letztes Backup:</strong> {new Date(cfg.lastRun).toLocaleString('de-CH')} {cfg.lastStatus === 'ok' ? '✅' : '❌'} {cfg.lastMessage}
</div>
)}
{/* Zeitplan */}
<div className="rounded-lg border p-4 space-y-3">
<h4 className="font-semibold text-sm">Zeitplan</h4>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Automatisch</Label>
<select value={cfg.schedule} onChange={e => patch({ schedule: e.target.value as any, enabled: e.target.value !== 'off' })} className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm">
<option value="off">Aus (nur manuell)</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
</select>
</div>
<div>
<Label className="text-xs">Aufbewahrung (Tage)</Label>
<Input type="number" min={1} max={3650} value={cfg.retentionDays} onChange={e => patch({ retentionDays: Number(e.target.value) })} className="h-9" />
</div>
</div>
</div>
{/* Verschlüsselung */}
<div className="rounded-lg border p-4 space-y-2">
<h4 className="font-semibold text-sm flex items-center gap-1.5"><ShieldCheck className="w-4 h-4" /> Verschlüsselung</h4>
<Label className="text-xs">Passphrase {cfg.hasPassphrase && <span className="text-green-600">· gesetzt</span>}</Label>
<Input type="password" value={passphrase} onChange={e => setPassphrase(e.target.value)} placeholder={cfg.hasPassphrase ? 'Zum Beibehalten leer lassen' : 'Passphrase wählen (sicher aufbewahren!)'} className="h-9" />
<p className="text-xs text-muted-foreground"> Ohne diese Passphrase ist kein Restore möglich im Passwort-Manager speichern.</p>
</div>
{/* Ziel */}
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-3">
<h4 className="font-semibold text-sm">Ziel</h4>
<select value={cfg.destinationType} onChange={e => patch({ destinationType: e.target.value as any })} className="h-8 rounded-md border border-input bg-background px-2 text-sm">
<option value="sftp">SFTP</option>
<option value="webdav">Nextcloud / WebDAV</option>
</select>
</div>
{cfg.destinationType === 'sftp' ? (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Host</Label><Input value={cfg.sftp?.host || ''} onChange={e => patchSftp({ host: e.target.value })} placeholder="nas.example.com" className="h-9" /></div>
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Port</Label><Input type="number" value={cfg.sftp?.port ?? 22} onChange={e => patchSftp({ port: Number(e.target.value) })} className="h-9" /></div>
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Benutzer</Label><Input value={cfg.sftp?.username || ''} onChange={e => patchSftp({ username: e.target.value })} className="h-9" /></div>
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Passwort {cfg.sftp?.hasPassword && <span className="text-green-600">· gesetzt</span>}</Label><Input type="password" value={sftpPw} onChange={e => setSftpPw(e.target.value)} placeholder={cfg.sftp?.hasPassword ? 'leer lassen' : ''} className="h-9" /></div>
<div className="col-span-2"><Label className="text-xs">Zielordner (Remote-Pfad)</Label><Input value={cfg.sftp?.remotePath || ''} onChange={e => patchSftp({ remotePath: e.target.value })} placeholder="/lageplan-backups" className="h-9" /></div>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2"><Label className="text-xs">WebDAV-URL</Label><Input value={cfg.webdav?.url || ''} onChange={e => patchWebdav({ url: e.target.value })} placeholder="https://cloud.example.com/remote.php/dav/files/USER/" className="h-9" /></div>
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Benutzer</Label><Input value={cfg.webdav?.username || ''} onChange={e => patchWebdav({ username: e.target.value })} className="h-9" /></div>
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">App-Passwort {cfg.webdav?.hasPassword && <span className="text-green-600">· gesetzt</span>}</Label><Input type="password" value={webdavPw} onChange={e => setWebdavPw(e.target.value)} placeholder={cfg.webdav?.hasPassword ? 'leer lassen' : 'Nextcloud App-Passwort'} className="h-9" /></div>
<div className="col-span-2"><Label className="text-xs">Zielordner</Label><Input value={cfg.webdav?.remotePath || ''} onChange={e => patchWebdav({ remotePath: e.target.value })} placeholder="/Lageplan-Backups" className="h-9" /></div>
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={save} disabled={busy !== ''}>{busy === 'save' ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} Speichern</Button>
<Button variant="outline" onClick={test} disabled={busy !== ''}>{busy === 'test' ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <PlugZap className="w-4 h-4 mr-1.5" />} Verbindung testen</Button>
<Button variant="outline" onClick={runNow} disabled={busy !== ''}>{busy === 'run' ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <HardDriveDownload className="w-4 h-4 mr-1.5" />} Jetzt sichern</Button>
</div>
<p className="text-xs text-muted-foreground">Speichern Verbindung testen Jetzt sichern. Restore: siehe <code>docs/BACKUP.md</code> (Entschlüsseln mit der Passphrase).</p>
</div>
)
}