Backup-Funktionalität
Dieser Commit ist enthalten in:
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import psycopg2
|
||||
from psycopg2.extras import Json
|
||||
from flask import Flask, render_template, request, redirect, session, url_for, send_file
|
||||
from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify
|
||||
from flask_session import Session
|
||||
from functools import wraps
|
||||
from dotenv import load_dotenv
|
||||
@@ -9,6 +9,13 @@ import pandas as pd
|
||||
from datetime import datetime
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import gzip
|
||||
from cryptography.fernet import Fernet
|
||||
from pathlib import Path
|
||||
import time
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
import logging
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -19,6 +26,17 @@ app.config['JSON_AS_ASCII'] = False # JSON-Ausgabe mit UTF-8
|
||||
app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8'
|
||||
Session(app)
|
||||
|
||||
# Backup-Konfiguration
|
||||
BACKUP_DIR = Path("/app/backups")
|
||||
BACKUP_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Scheduler für automatische Backups
|
||||
scheduler = BackgroundScheduler()
|
||||
scheduler.start()
|
||||
|
||||
# Logging konfigurieren
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# Login decorator
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
@@ -72,6 +90,238 @@ def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=N
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Verschlüsselungs-Funktionen
|
||||
def get_or_create_encryption_key():
|
||||
"""Holt oder erstellt einen Verschlüsselungsschlüssel"""
|
||||
key_file = BACKUP_DIR / ".backup_key"
|
||||
|
||||
# Versuche Key aus Umgebungsvariable zu lesen
|
||||
env_key = os.getenv("BACKUP_ENCRYPTION_KEY")
|
||||
if env_key:
|
||||
try:
|
||||
# Validiere den Key
|
||||
Fernet(env_key.encode())
|
||||
return env_key.encode()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Wenn kein gültiger Key in ENV, prüfe Datei
|
||||
if key_file.exists():
|
||||
return key_file.read_bytes()
|
||||
|
||||
# Erstelle neuen Key
|
||||
key = Fernet.generate_key()
|
||||
key_file.write_bytes(key)
|
||||
logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt")
|
||||
return key
|
||||
|
||||
# Backup-Funktionen
|
||||
def create_backup(backup_type="manual", created_by=None):
|
||||
"""Erstellt ein verschlüsseltes Backup der Datenbank"""
|
||||
start_time = time.time()
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc"
|
||||
filepath = BACKUP_DIR / filename
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Backup-Eintrag erstellen
|
||||
cur.execute("""
|
||||
INSERT INTO backup_history
|
||||
(filename, filepath, backup_type, status, created_by, is_encrypted)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (filename, str(filepath), backup_type, 'in_progress',
|
||||
created_by or 'system', True))
|
||||
backup_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
# PostgreSQL Dump erstellen
|
||||
dump_command = [
|
||||
'pg_dump',
|
||||
'-h', os.getenv("POSTGRES_HOST", "postgres"),
|
||||
'-p', os.getenv("POSTGRES_PORT", "5432"),
|
||||
'-U', os.getenv("POSTGRES_USER"),
|
||||
'-d', os.getenv("POSTGRES_DB"),
|
||||
'--no-password',
|
||||
'--verbose'
|
||||
]
|
||||
|
||||
# PGPASSWORD setzen
|
||||
env = os.environ.copy()
|
||||
env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD")
|
||||
|
||||
# Dump ausführen
|
||||
result = subprocess.run(dump_command, capture_output=True, text=True, env=env)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"pg_dump failed: {result.stderr}")
|
||||
|
||||
dump_data = result.stdout.encode('utf-8')
|
||||
|
||||
# Komprimieren
|
||||
compressed_data = gzip.compress(dump_data)
|
||||
|
||||
# Verschlüsseln
|
||||
key = get_or_create_encryption_key()
|
||||
f = Fernet(key)
|
||||
encrypted_data = f.encrypt(compressed_data)
|
||||
|
||||
# Speichern
|
||||
filepath.write_bytes(encrypted_data)
|
||||
|
||||
# Statistiken sammeln
|
||||
cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'")
|
||||
tables_count = cur.fetchone()[0]
|
||||
|
||||
cur.execute("""
|
||||
SELECT SUM(n_live_tup)
|
||||
FROM pg_stat_user_tables
|
||||
""")
|
||||
records_count = cur.fetchone()[0] or 0
|
||||
|
||||
duration = time.time() - start_time
|
||||
filesize = filepath.stat().st_size
|
||||
|
||||
# Backup-Eintrag aktualisieren
|
||||
cur.execute("""
|
||||
UPDATE backup_history
|
||||
SET status = %s, filesize = %s, tables_count = %s,
|
||||
records_count = %s, duration_seconds = %s
|
||||
WHERE id = %s
|
||||
""", ('success', filesize, tables_count, records_count, duration, backup_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit-Log
|
||||
log_audit('BACKUP', 'database', backup_id,
|
||||
additional_info=f"Backup erstellt: {filename} ({filesize} bytes)")
|
||||
|
||||
# E-Mail-Benachrichtigung (wenn konfiguriert)
|
||||
send_backup_notification(True, filename, filesize, duration)
|
||||
|
||||
logging.info(f"Backup erfolgreich erstellt: {filename}")
|
||||
return True, filename
|
||||
|
||||
except Exception as e:
|
||||
# Fehler protokollieren
|
||||
cur.execute("""
|
||||
UPDATE backup_history
|
||||
SET status = %s, error_message = %s, duration_seconds = %s
|
||||
WHERE id = %s
|
||||
""", ('failed', str(e), time.time() - start_time, backup_id))
|
||||
conn.commit()
|
||||
|
||||
logging.error(f"Backup fehlgeschlagen: {e}")
|
||||
send_backup_notification(False, filename, error=str(e))
|
||||
|
||||
return False, str(e)
|
||||
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def restore_backup(backup_id, encryption_key=None):
|
||||
"""Stellt ein Backup wieder her"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Backup-Info abrufen
|
||||
cur.execute("""
|
||||
SELECT filename, filepath, is_encrypted
|
||||
FROM backup_history
|
||||
WHERE id = %s
|
||||
""", (backup_id,))
|
||||
backup_info = cur.fetchone()
|
||||
|
||||
if not backup_info:
|
||||
raise Exception("Backup nicht gefunden")
|
||||
|
||||
filename, filepath, is_encrypted = backup_info
|
||||
filepath = Path(filepath)
|
||||
|
||||
if not filepath.exists():
|
||||
raise Exception("Backup-Datei nicht gefunden")
|
||||
|
||||
# Datei lesen
|
||||
encrypted_data = filepath.read_bytes()
|
||||
|
||||
# Entschlüsseln
|
||||
if is_encrypted:
|
||||
key = encryption_key.encode() if encryption_key else get_or_create_encryption_key()
|
||||
try:
|
||||
f = Fernet(key)
|
||||
compressed_data = f.decrypt(encrypted_data)
|
||||
except:
|
||||
raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?")
|
||||
else:
|
||||
compressed_data = encrypted_data
|
||||
|
||||
# Dekomprimieren
|
||||
dump_data = gzip.decompress(compressed_data)
|
||||
sql_commands = dump_data.decode('utf-8')
|
||||
|
||||
# Bestehende Verbindungen schließen
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Datenbank wiederherstellen
|
||||
restore_command = [
|
||||
'psql',
|
||||
'-h', os.getenv("POSTGRES_HOST", "postgres"),
|
||||
'-p', os.getenv("POSTGRES_PORT", "5432"),
|
||||
'-U', os.getenv("POSTGRES_USER"),
|
||||
'-d', os.getenv("POSTGRES_DB"),
|
||||
'--no-password'
|
||||
]
|
||||
|
||||
env = os.environ.copy()
|
||||
env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD")
|
||||
|
||||
result = subprocess.run(restore_command, input=sql_commands,
|
||||
capture_output=True, text=True, env=env)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}")
|
||||
|
||||
# Audit-Log (neue Verbindung)
|
||||
log_audit('RESTORE', 'database', backup_id,
|
||||
additional_info=f"Backup wiederhergestellt: {filename}")
|
||||
|
||||
return True, "Backup erfolgreich wiederhergestellt"
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Wiederherstellung fehlgeschlagen: {e}")
|
||||
return False, str(e)
|
||||
|
||||
def send_backup_notification(success, filename, filesize=None, duration=None, error=None):
|
||||
"""Sendet E-Mail-Benachrichtigung (wenn konfiguriert)"""
|
||||
if not os.getenv("EMAIL_ENABLED", "false").lower() == "true":
|
||||
return
|
||||
|
||||
# E-Mail-Funktion vorbereitet aber deaktiviert
|
||||
# TODO: Implementieren wenn E-Mail-Server konfiguriert ist
|
||||
logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}")
|
||||
|
||||
# Scheduled Backup Job
|
||||
def scheduled_backup():
|
||||
"""Führt ein geplantes Backup aus"""
|
||||
logging.info("Starte geplantes Backup...")
|
||||
create_backup(backup_type="scheduled", created_by="scheduler")
|
||||
|
||||
# Scheduler konfigurieren - täglich um 3:00 Uhr
|
||||
scheduler.add_job(
|
||||
scheduled_backup,
|
||||
'cron',
|
||||
hour=3,
|
||||
minute=0,
|
||||
id='daily_backup',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if request.method == "POST":
|
||||
@@ -182,6 +432,15 @@ def dashboard():
|
||||
""")
|
||||
expiring_licenses = cur.fetchall()
|
||||
|
||||
# Letztes Backup
|
||||
cur.execute("""
|
||||
SELECT created_at, filesize, duration_seconds, backup_type, status
|
||||
FROM backup_history
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
last_backup_info = cur.fetchone()
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
@@ -195,7 +454,8 @@ def dashboard():
|
||||
'test_licenses': license_types.get('test', 0),
|
||||
'recent_licenses': recent_licenses,
|
||||
'expiring_licenses': expiring_licenses,
|
||||
'active_sessions': active_sessions_count
|
||||
'active_sessions': active_sessions_count,
|
||||
'last_backup': last_backup_info
|
||||
}
|
||||
|
||||
return render_template("dashboard.html", stats=stats, username=session.get('username'))
|
||||
@@ -911,5 +1171,109 @@ def audit_log():
|
||||
total=total,
|
||||
username=session.get('username'))
|
||||
|
||||
@app.route("/backups")
|
||||
@login_required
|
||||
def backups():
|
||||
"""Zeigt die Backup-Historie an"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Letztes erfolgreiches Backup für Dashboard
|
||||
cur.execute("""
|
||||
SELECT created_at, filesize, duration_seconds
|
||||
FROM backup_history
|
||||
WHERE status = 'success'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
last_backup = cur.fetchone()
|
||||
|
||||
# Alle Backups abrufen
|
||||
cur.execute("""
|
||||
SELECT id, filename, filesize, backup_type, status, error_message,
|
||||
created_at, created_by, tables_count, records_count,
|
||||
duration_seconds, is_encrypted
|
||||
FROM backup_history
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
backups = cur.fetchall()
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return render_template("backups.html",
|
||||
backups=backups,
|
||||
last_backup=last_backup,
|
||||
username=session.get('username'))
|
||||
|
||||
@app.route("/backup/create", methods=["POST"])
|
||||
@login_required
|
||||
def create_backup_route():
|
||||
"""Erstellt ein manuelles Backup"""
|
||||
username = session.get('username')
|
||||
success, result = create_backup(backup_type="manual", created_by=username)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Backup erfolgreich erstellt: {result}'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'Backup fehlgeschlagen: {result}'
|
||||
}), 500
|
||||
|
||||
@app.route("/backup/restore/<int:backup_id>", methods=["POST"])
|
||||
@login_required
|
||||
def restore_backup_route(backup_id):
|
||||
"""Stellt ein Backup wieder her"""
|
||||
encryption_key = request.form.get('encryption_key')
|
||||
|
||||
success, message = restore_backup(backup_id, encryption_key)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': message
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': message
|
||||
}), 500
|
||||
|
||||
@app.route("/backup/download/<int:backup_id>")
|
||||
@login_required
|
||||
def download_backup(backup_id):
|
||||
"""Lädt eine Backup-Datei herunter"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT filename, filepath
|
||||
FROM backup_history
|
||||
WHERE id = %s
|
||||
""", (backup_id,))
|
||||
backup_info = cur.fetchone()
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if not backup_info:
|
||||
return "Backup nicht gefunden", 404
|
||||
|
||||
filename, filepath = backup_info
|
||||
filepath = Path(filepath)
|
||||
|
||||
if not filepath.exists():
|
||||
return "Backup-Datei nicht gefunden", 404
|
||||
|
||||
# Audit-Log
|
||||
log_audit('DOWNLOAD', 'backup', backup_id,
|
||||
additional_info=f"Backup heruntergeladen: {filename}")
|
||||
|
||||
return send_file(filepath, as_attachment=True, download_name=filename)
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=443, ssl_context='adhoc')
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren