feat: automatisches SSH-Backup nach jedem Logbuch-Eintrag

Nach jedem POST und PUT im Logbuch wird mysqldump (ohne beos-Tabelle)
via gzip | ssh auf einen externen Server übertragen. Backups älter als
30 Tage werden automatisch gelöscht. BACKUP_SSH_URL und
BACKUP_SSH_KEY_PATH in .env konfigurieren; SSH-Key als Volume mounten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 15:21:01 +02:00
parent 8c60089325
commit cf95f3027f
4 changed files with 82 additions and 0 deletions
+2
View File
@@ -23,6 +23,8 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN apk add --no-cache mysql-client openssh-client
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
+2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query, getPool } from '@/lib/db'; import { query, getPool } from '@/lib/db';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import { triggerBackup } from '@/lib/backup';
import type { SelectedObjekt } from '@/types/logbuch'; import type { SelectedObjekt } from '@/types/logbuch';
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
@@ -68,6 +69,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
); );
} }
triggerBackup();
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
console.error('PUT /api/logbuch/[id]:', error); console.error('PUT /api/logbuch/[id]:', error);
+2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query, getPool } from '@/lib/db'; import { query, getPool } from '@/lib/db';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import { triggerBackup } from '@/lib/backup';
import type { SelectedObjekt } from '@/types/logbuch'; import type { SelectedObjekt } from '@/types/logbuch';
const LIST_SQL = const LIST_SQL =
@@ -137,6 +138,7 @@ export async function POST(request: NextRequest) {
); );
} }
triggerBackup();
return NextResponse.json({ id: logbuchId }, { status: 201 }); return NextResponse.json({ id: logbuchId }, { status: 201 });
} catch (error) { } catch (error) {
console.error('POST /api/logbuch:', error); console.error('POST /api/logbuch:', error);
+76
View File
@@ -0,0 +1,76 @@
import { spawn } from 'child_process';
export function triggerBackup(): void {
runBackup().catch((e) => console.error('[backup] Fehler:', e));
}
async function runBackup(): Promise<void> {
const sshUrl = process.env.BACKUP_SSH_URL;
if (!sshUrl) return;
const match = sshUrl.match(/^([^:]+):(.+)$/);
if (!match) {
console.error('[backup] BACKUP_SSH_URL muss das Format user@host:/pfad haben');
return;
}
const [, sshHost, remotePath] = match;
const keyPath = process.env.BACKUP_SSH_KEY_PATH || '/app/.ssh/id_rsa';
const ts = new Date().toISOString().replace('T', '_').replace(/:/g, '-').slice(0, 19);
const filename = `sternwarte_${ts}.sql.gz`;
const dbHost = process.env.DB_HOST || 'db';
const dbPort = process.env.DB_PORT || '3306';
const dbUser = process.env.DB_USER || '';
const dbPass = process.env.DB_PASS || '';
const dbName = process.env.DB_NAME || 'sternwarte';
const sshOpts = [
'-i', keyPath,
'-o', 'StrictHostKeyChecking=no',
'-o', 'BatchMode=yes',
];
await new Promise<void>((resolve, reject) => {
const dump = spawn('mysqldump', [
`-h${dbHost}`, `-P${dbPort}`, `-u${dbUser}`, `-p${dbPass}`,
`--ignore-table=${dbName}.beos`,
dbName,
]);
const gzip = spawn('gzip');
const ssh = spawn('ssh', [...sshOpts, sshHost, `cat > ${remotePath}/${filename}`]);
dump.stdout.pipe(gzip.stdin);
gzip.stdout.pipe(ssh.stdin);
let dumpErr = '';
let sshErr = '';
dump.stderr.on('data', (d: Buffer) => { dumpErr += d.toString(); });
ssh.stderr.on('data', (d: Buffer) => { sshErr += d.toString(); });
dump.on('close', (code) => { if (code !== 0) gzip.stdin.end(); });
gzip.on('close', () => ssh.stdin.end());
ssh.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`ssh exit ${code}${sshErr ? ': ' + sshErr.trim() : ''}${dumpErr ? ' | dump: ' + dumpErr.trim() : ''}`));
}
});
});
// Backups älter als 30 Tage löschen
await new Promise<void>((resolve) => {
const ssh = spawn('ssh', [
...sshOpts, sshHost,
`find ${remotePath} -name 'sternwarte_*.sql.gz' -mtime +30 -delete`,
]);
ssh.on('close', (code) => {
if (code !== 0) console.error('[backup] Cleanup fehlgeschlagen (exit ' + code + ')');
resolve();
});
});
console.log(`[backup] ${filename}${sshHost}:${remotePath}`);
}