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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
1048
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
51
scripts/decrypt-backup.js
Normal 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) })
|
||||||
@@ -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)')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
55
src/app/api/admin/backup/config/route.ts
Normal file
55
src/app/api/admin/backup/config/route.ts
Normal 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))
|
||||||
|
}
|
||||||
17
src/app/api/admin/backup/run/route.ts
Normal file
17
src/app/api/admin/backup/run/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/api/admin/backup/test/route.ts
Normal file
23
src/app/api/admin/backup/test/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/app/api/cron/backup/route.ts
Normal file
24
src/app/api/cron/backup/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/components/admin/backup-tab.tsx
Normal file
160
src/components/admin/backup-tab.tsx
Normal 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
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 || '')
|
||||||
|
}
|
||||||
49
src/lib/crypto-secret.ts
Normal file
49
src/lib/crypto-secret.ts
Normal 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')
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user