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

@@ -38,3 +38,11 @@ GITEA_REGISTRY_PASS=dein_gitea_token_oder_passwort
BACKUP_RETENTION_DAYS=30 BACKUP_RETENTION_DAYS=30
# Intervall in Stunden (24 = taeglich) # Intervall in Stunden (24 = taeglich)
BACKUP_INTERVAL_HOURS=24 BACKUP_INTERVAL_HOURS=24
# --- GUI-Backup (SFTP/Nextcloud, verschluesselt) ---
# Noetig fuer den automatischen Zeitplan (interner Scheduler ruft /api/cron/backup):
CRON_SECRET=langer-zufaelliger-wert-min-32-zeichen
# Optional: eigener Schluessel zum Verschluesseln der gespeicherten Zugangsdaten
# (Default: NEXTAUTH_SECRET). Wenn gesetzt, NICHT mehr aendern (sonst sind gespeicherte
# Zugangsdaten/Passphrase nicht mehr entschluesselbar):
# BACKUP_ENC_KEY=eigener-langer-schluessel

View File

@@ -28,7 +28,8 @@ RUN --mount=type=cache,target=/app/.next/cache npm run build
# Stage 3: Runner # Stage 3: Runner
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
RUN apk add --no-cache openssl # openssl (Prisma) + postgresql16-client (pg_dump/pg_restore für GUI-Backup, passend zu postgres:16)
RUN apk add --no-cache openssl postgresql16-client
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
@@ -44,7 +45,7 @@ USER nextjs
# Install only the unbundled runtime deps needed by the custom server. # Install only the unbundled runtime deps needed by the custom server.
# Running as USER nextjs means files are already correctly owned — no slow chown -R needed afterwards. # Running as USER nextjs means files are already correctly owned — no slow chown -R needed afterwards.
RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 --no-save RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 ssh2-sftp-client@12.1.1 webdav@5.10.0 archiver@8.0.0 --no-save
COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone ./ COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone ./
COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static

View File

@@ -80,3 +80,36 @@ Empfehlung: **halbjährlich** auf einer Testumgebung durchspielen.
- E-Mails liegen beim SMTP-Anbieter. - E-Mails liegen beim SMTP-Anbieter.
- Secrets (`NEXTAUTH_SECRET`, MinIO-Keys) — separat sicher aufbewahren (Passwort-Manager), - Secrets (`NEXTAUTH_SECRET`, MinIO-Keys) — separat sicher aufbewahren (Passwort-Manager),
sie stehen nicht in den Backups. sie stehen nicht in den Backups.
---
# GUI-Backup (verschlüsselt, extern: SFTP / Nextcloud)
Zusätzlich zum lokalen Container-Backup gibt es ein **über die Oberfläche konfigurierbares**,
verschlüsseltes Off-Site-Backup. Als **SERVER_ADMIN**: **Administration → Backup**.
- **Ziele:** SFTP oder Nextcloud (WebDAV).
- **Verschlüsselung:** AES-256-GCM mit einer **Passphrase** (im Passwort-Manager aufbewahren —
ohne sie ist kein Restore möglich). Zugangsdaten werden verschlüsselt in der DB gespeichert.
- **Zeitplan:** aus / täglich / wöchentlich + Aufbewahrung in Tagen (prunt alte Backups am Ziel).
- **Ablauf:** Speichern → **Verbindung testen****Jetzt sichern**. Der automatische Lauf braucht
`CRON_SECRET` (siehe `.env.example`); der interne Scheduler prüft stündlich die Fälligkeit.
Hochgeladen wird eine Datei `lageplan_<zeitstempel>.tar.gz.enc` (enthält `database.dump` + `files/`).
## Restore eines GUI-Backups
```
# 1) Datei vom Ziel herunterladen, dann entschlüsseln (Passphrase bereithalten):
node scripts/decrypt-backup.js lageplan_2026-07-23-03-00-00.tar.gz.enc backup.tar.gz "DEINE-PASSPHRASE"
# 2) Entpacken
tar xzf backup.tar.gz # -> database.dump + files/
# 3) Datenbank wiederherstellen (destruktiv, App vorher stoppen)
docker cp database.dump lageplan-db:/tmp/database.dump
docker exec lageplan-db pg_restore --clean --if-exists --no-owner -U <POSTGRES_USER> -d <POSTGRES_DB> /tmp/database.dump
# 4) Dateien zurück in MinIO (Ordner files/ in den Bucket)
docker run --rm -v minio_data:/data -v "$PWD/files":/src alpine sh -c "cp -r /src/* /data/<MINIO_BUCKET>/ 2>/dev/null || true"
```
Danach App wieder starten. **Restore einmal testen** (Tabelle oben ausfüllen).

View File

@@ -6,6 +6,9 @@ const nextConfig = {
APP_VERSION: packageJson.version, APP_VERSION: packageJson.version,
}, },
output: 'standalone', output: 'standalone',
// Node-Libs für das Backup nicht bundeln, sondern zur Laufzeit aus node_modules laden
// (ssh2 hat optionale native Module; webdav/archiver sind reine Server-Abhängigkeiten).
serverExternalPackages: ['ssh2', 'ssh2-sftp-client', 'archiver', 'webdav'],
async headers() { async headers() {
return [ return [
{ {

1048
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.8.11", "version": "1.9.0",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -45,6 +45,7 @@
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"archiver": "^8.0.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
@@ -68,10 +69,12 @@
"react-moveable": "^0.56.0", "react-moveable": "^0.56.0",
"socket.io": "^4.7.4", "socket.io": "^4.7.4",
"socket.io-client": "^4.7.4", "socket.io-client": "^4.7.4",
"ssh2-sftp-client": "^12.1.1",
"stripe": "^20.3.1", "stripe": "^20.3.1",
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"webdav": "^5.10.0",
"zod": "^3.22.4", "zod": "^3.22.4",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
@@ -79,11 +82,13 @@
"seed": "node prisma/seed.js" "seed": "node prisma/seed.js"
}, },
"devDependencies": { "devDependencies": {
"@types/archiver": "^8.0.0",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@types/ssh2-sftp-client": "^9.0.6",
"@types/uuid": "^9.0.7", "@types/uuid": "^9.0.7",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"eslint": "^8.56.0", "eslint": "^8.56.0",

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) })

View File

@@ -71,4 +71,21 @@ app.prepare().then(() => {
httpServer.listen(port, hostname, () => { httpServer.listen(port, hostname, () => {
console.log(`> Ready on http://${hostname}:${port}`) console.log(`> Ready on http://${hostname}:${port}`)
}) })
// Backup-Scheduler: stündlich prüfen, ob laut Zeitplan ein Backup fällig ist.
// Die Route /api/cron/backup entscheidet anhand der Konfiguration; hier wird nur getriggert.
if (process.env.CRON_SECRET) {
const checkBackup = () => {
fetch(`http://127.0.0.1:${port}/api/cron/backup`, {
method: 'POST',
headers: { 'x-cron-secret': process.env.CRON_SECRET },
})
.then((r) => r.json())
.then((d) => { if (d && d.skipped !== true) console.log('[backup-scheduler]', JSON.stringify(d)) })
.catch((e) => console.warn('[backup-scheduler]', e.message))
}
setInterval(checkBackup, 60 * 60 * 1000) // stündlich
setTimeout(checkBackup, 90 * 1000) // ~1.5 Min nach Start einmal
console.log('> Backup-Scheduler aktiv (stündliche Fälligkeitsprüfung)')
}
}) })

View File

@@ -9,6 +9,7 @@ import { useAuth } from '@/components/providers/auth-provider'
import { import {
ArrowLeft, MapPin, Shield, Map, Image as ImageIcon, Layers, BookOpen, ArrowLeft, MapPin, Shield, Map, Image as ImageIcon, Layers, BookOpen,
Users, Settings, Building2, AlertTriangle, ClipboardList, Heart, ShieldCheck, Loader2, Users, Settings, Building2, AlertTriangle, ClipboardList, Heart, ShieldCheck, Loader2,
HardDriveDownload,
} from 'lucide-react' } from 'lucide-react'
import { TenantsTab } from '@/components/admin/tenants-tab' import { TenantsTab } from '@/components/admin/tenants-tab'
import { UsersTab } from '@/components/admin/users-tab' import { UsersTab } from '@/components/admin/users-tab'
@@ -21,6 +22,7 @@ import { SuggestionsTab } from '@/components/admin/suggestions-tab'
import { DictionaryTab } from '@/components/admin/dictionary-tab' import { DictionaryTab } from '@/components/admin/dictionary-tab'
import { SymbolManager } from '@/components/admin/symbol-manager' import { SymbolManager } from '@/components/admin/symbol-manager'
import { OrgTab } from '@/components/admin/org-tab' import { OrgTab } from '@/components/admin/org-tab'
import { BackupTab } from '@/components/admin/backup-tab'
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog' import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
export default function AdminPage() { export default function AdminPage() {
@@ -81,7 +83,7 @@ export default function AdminPage() {
<div className="container mx-auto py-6 px-4 max-w-7xl"> <div className="container mx-auto py-6 px-4 max-w-7xl">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
{isServerAdmin ? ( {isServerAdmin ? (
<TabsList className="w-full max-w-4xl h-auto flex flex-nowrap overflow-x-auto justify-start md:grid md:grid-cols-7 [&>button]:shrink-0 [&>button]:whitespace-nowrap"> <TabsList className="w-full max-w-5xl h-auto flex flex-nowrap overflow-x-auto justify-start md:grid md:grid-cols-8 [&>button]:shrink-0 [&>button]:whitespace-nowrap">
<TabsTrigger value="tenants" className="gap-2"><Shield className="w-4 h-4" />Mandanten</TabsTrigger> <TabsTrigger value="tenants" className="gap-2"><Shield className="w-4 h-4" />Mandanten</TabsTrigger>
<TabsTrigger value="projects" className="gap-2"><Map className="w-4 h-4" />Einsätze</TabsTrigger> <TabsTrigger value="projects" className="gap-2"><Map className="w-4 h-4" />Einsätze</TabsTrigger>
<TabsTrigger value="icons" className="gap-2"><ImageIcon className="w-4 h-4" />Symbole</TabsTrigger> <TabsTrigger value="icons" className="gap-2"><ImageIcon className="w-4 h-4" />Symbole</TabsTrigger>
@@ -89,6 +91,7 @@ export default function AdminPage() {
<TabsTrigger value="dictionary" className="gap-2"><BookOpen className="w-4 h-4" />Wörterbuch</TabsTrigger> <TabsTrigger value="dictionary" className="gap-2"><BookOpen className="w-4 h-4" />Wörterbuch</TabsTrigger>
<TabsTrigger value="users" className="gap-2"><Users className="w-4 h-4" />Benutzer</TabsTrigger> <TabsTrigger value="users" className="gap-2"><Users className="w-4 h-4" />Benutzer</TabsTrigger>
<TabsTrigger value="settings" className="gap-2"><Settings className="w-4 h-4" />System</TabsTrigger> <TabsTrigger value="settings" className="gap-2"><Settings className="w-4 h-4" />System</TabsTrigger>
<TabsTrigger value="backup" className="gap-2"><HardDriveDownload className="w-4 h-4" />Backup</TabsTrigger>
</TabsList> </TabsList>
) : ( ) : (
<TabsList className="w-full max-w-4xl h-auto flex flex-nowrap overflow-x-auto justify-start md:grid md:grid-cols-7 [&>button]:shrink-0 [&>button]:whitespace-nowrap"> <TabsList className="w-full max-w-4xl h-auto flex flex-nowrap overflow-x-auto justify-start md:grid md:grid-cols-7 [&>button]:shrink-0 [&>button]:whitespace-nowrap">
@@ -121,6 +124,10 @@ export default function AdminPage() {
</TabsContent> </TabsContent>
)} )}
{isServerAdmin && (
<TabsContent value="backup"><BackupTab /></TabsContent>
)}
{/* ===== Geteilt: Benutzer ===== */} {/* ===== Geteilt: Benutzer ===== */}
<TabsContent value="users"><UsersTab /></TabsContent> <TabsContent value="users"><UsersTab /></TabsContent>

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/crypto-secret'
import { loadBackupConfig, saveBackupConfig, maskBackupConfig, DEFAULT_BACKUP_CONFIG, type BackupConfig } from '@/lib/backup/config'
// Backup ist plattformweit → nur SERVER_ADMIN.
async function guard() {
const user = await getSession()
if (!user || user.role !== 'SERVER_ADMIN') return null
return user
}
export async function GET() {
if (!(await guard())) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const cfg = (await loadBackupConfig()) || DEFAULT_BACKUP_CONFIG
return NextResponse.json(maskBackupConfig(cfg))
}
export async function PUT(req: NextRequest) {
if (!(await guard())) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const body = await req.json().catch(() => ({}))
const existing = (await loadBackupConfig()) || DEFAULT_BACKUP_CONFIG
const next: BackupConfig = { ...existing }
next.enabled = !!body.enabled
next.schedule = ['off', 'daily', 'weekly'].includes(body.schedule) ? body.schedule : 'off'
next.retentionDays = Math.max(1, Math.min(3650, Number(body.retentionDays) || 30))
next.destinationType = body.destinationType === 'webdav' ? 'webdav' : 'sftp'
// Passphrase nur ersetzen, wenn neu eingegeben
if (typeof body.passphrase === 'string' && body.passphrase.length > 0) {
next.encryptPassphrase = encryptSecret(body.passphrase)
}
if (body.sftp) {
next.sftp = {
host: String(body.sftp.host || ''),
port: Number(body.sftp.port) || 22,
username: String(body.sftp.username || ''),
remotePath: String(body.sftp.remotePath || '/lageplan-backups'),
password: body.sftp.password ? encryptSecret(String(body.sftp.password)) : (existing.sftp?.password || ''),
}
}
if (body.webdav) {
next.webdav = {
url: String(body.webdav.url || ''),
username: String(body.webdav.username || ''),
remotePath: String(body.webdav.remotePath || '/Lageplan-Backups'),
password: body.webdav.password ? encryptSecret(String(body.webdav.password)) : (existing.webdav?.password || ''),
}
}
await saveBackupConfig(next)
return NextResponse.json(maskBackupConfig(next))
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { runBackup } from '@/lib/backup/engine'
// POST: löst sofort ein Backup aus (manuell „Jetzt sichern"). Nur SERVER_ADMIN.
export async function POST() {
try {
const user = await getSession()
if (!user || user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const res = await runBackup()
return NextResponse.json(res)
} catch (e: any) {
return NextResponse.json({ ok: false, message: e?.message || 'Backup fehlgeschlagen' }, { status: 500 })
}
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { loadBackupConfig } from '@/lib/backup/config'
import { plainDestFromConfig } from '@/lib/backup/secrets'
import { testDestination } from '@/lib/backup/destinations'
// POST: testet die gespeicherte Zielverbindung (Verbindung + Schreibzugriff). Nur SERVER_ADMIN.
export async function POST() {
try {
const user = await getSession()
if (!user || user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const cfg = await loadBackupConfig()
if (!cfg) return NextResponse.json({ ok: false, message: 'Bitte zuerst konfigurieren und speichern.' }, { status: 400 })
const dest = plainDestFromConfig(cfg)
const res = await testDestination(cfg.destinationType, dest)
return NextResponse.json(res, { status: res.ok ? 200 : 400 })
} catch (e: any) {
return NextResponse.json({ ok: false, message: e?.message || 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { loadBackupConfig } from '@/lib/backup/config'
import { isBackupDue, runBackup } from '@/lib/backup/engine'
// POST (öffentlich, aber per CRON_SECRET geschützt): vom internen Scheduler aufgerufen.
// Führt ein Backup NUR aus, wenn es laut Zeitplan fällig ist.
export async function POST(req: NextRequest) {
try {
const secret = process.env.CRON_SECRET
if (!secret) return NextResponse.json({ error: 'CRON_SECRET nicht gesetzt' }, { status: 503 })
if (req.headers.get('x-cron-secret') !== secret) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
const cfg = await loadBackupConfig()
if (!cfg) return NextResponse.json({ skipped: true, reason: 'nicht konfiguriert' })
if (!isBackupDue(cfg)) return NextResponse.json({ skipped: true, reason: 'nicht fällig' })
const res = await runBackup()
return NextResponse.json(res)
} catch (e: any) {
return NextResponse.json({ ok: false, message: e?.message || 'Fehler' }, { status: 500 })
}
}

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>
)
}

87
src/lib/backup/config.ts Normal file
View 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,
}
}

View 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
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
}

19
src/lib/backup/secrets.ts Normal file
View 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 || '')
}

49
src/lib/crypto-secret.ts Normal file
View File

@@ -0,0 +1,49 @@
import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from 'crypto'
/**
* Verschlüsselt kurze Geheimnisse (z.B. SFTP-/WebDAV-Passwörter, Backup-Passphrase) für die
* Ablage in der Datenbank. AES-256-GCM. Der Schlüssel wird aus einem Server-Secret abgeleitet
* (BACKUP_ENC_KEY oder NEXTAUTH_SECRET) — er liegt NICHT in der Datenbank.
*
* Format (base64): [salt(16)][iv(12)][tag(16)][ciphertext]
* Ein Präfix "enc:v1:" kennzeichnet verschlüsselte Werte.
*/
const PREFIX = 'enc:v1:'
function masterKey(): string {
const k = process.env.BACKUP_ENC_KEY || process.env.NEXTAUTH_SECRET
if (!k || k.length < 16) {
throw new Error('Kein Verschlüsselungs-Secret (BACKUP_ENC_KEY oder NEXTAUTH_SECRET) gesetzt.')
}
return k
}
export function isEncrypted(value: string | null | undefined): boolean {
return typeof value === 'string' && value.startsWith(PREFIX)
}
export function encryptSecret(plain: string): string {
if (!plain) return ''
const salt = randomBytes(16)
const iv = randomBytes(12)
const key = scryptSync(masterKey(), salt, 32)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return PREFIX + Buffer.concat([salt, iv, tag, ct]).toString('base64')
}
export function decryptSecret(value: string): string {
if (!value) return ''
if (!isEncrypted(value)) return value // Abwärtskompatibel: unverschlüsselte Altwerte
const raw = Buffer.from(value.slice(PREFIX.length), 'base64')
const salt = raw.subarray(0, 16)
const iv = raw.subarray(16, 28)
const tag = raw.subarray(28, 44)
const ct = raw.subarray(44)
const key = scryptSync(masterKey(), salt, 32)
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8')
}

View File

@@ -18,6 +18,7 @@ const PUBLIC_API_PREFIXES = [
'/api/auth/resend-verification', '/api/auth/resend-verification',
'/api/auth/logout', '/api/auth/logout',
'/api/auth/mfa-login/', '/api/auth/mfa-login/',
'/api/cron/',
'/api/contact', '/api/contact',
'/api/demo', '/api/demo',
'/api/donate', '/api/donate',