diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 24ce9da..9029e15 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,8 @@ "Bash(docker network inspect:*)", "Bash(mkdir:*)", "Bash(sudo touch:*)", - "Bash(docker volume rm:*)" + "Bash(docker volume rm:*)", + "Bash(rm:*)" ], "deny": [] } diff --git a/JOURNAL.md b/JOURNAL.md index ba4b044..04592bb 100644 --- a/JOURNAL.md +++ b/JOURNAL.md @@ -376,4 +376,37 @@ Die Session-Daten werden erst gefüllt, wenn der License Server API implementier - Basis-Image: postgres:14 - Locale-Installation über apt-get - locale-gen für de_DE.UTF-8 -- Vollständige UTF-8 Unterstützung für deutsche Sonderzeichen \ No newline at end of file +- Vollständige UTF-8 Unterstützung für deutsche Sonderzeichen + +### 2025-01-07 - Backup-Funktionalität implementiert +- Verschlüsselte Backups mit manueller und automatischer Ausführung +- Backup-Historie mit Download und Wiederherstellung +- Dashboard-Integration für Backup-Status + +**Neue Features:** +- **Backup-Erstellung**: Manuell und automatisch (täglich 3:00 Uhr) +- **Verschlüsselung**: AES-256 mit Fernet, Key aus ENV oder automatisch generiert +- **Komprimierung**: GZIP-Komprimierung vor Verschlüsselung +- **Backup-Historie**: Vollständige Übersicht aller Backups +- **Wiederherstellung**: Mit optionalem Verschlüsselungs-Passwort +- **Download-Funktion**: Backups können heruntergeladen werden +- **Dashboard-Widget**: Zeigt letztes Backup-Status +- **E-Mail-Vorbereitung**: Struktur für Benachrichtigungen (deaktiviert) + +**Neue/Geänderte Dateien:** +- v2_adminpanel/init.sql (backup_history Tabelle hinzugefügt) +- v2_adminpanel/requirements.txt (cryptography, apscheduler hinzugefügt) +- v2_adminpanel/app.py (Backup-Funktionen und Routen) +- v2_adminpanel/templates/backups.html (neu erstellt) +- v2_adminpanel/templates/dashboard.html (Backup-Status-Widget) +- v2_adminpanel/Dockerfile (PostgreSQL-Client installiert) +- v2/.env (EMAIL_ENABLED und BACKUP_ENCRYPTION_KEY) +- Alle Templates (Backup-Navigation hinzugefügt) + +**Technische Details:** +- Speicherort: C:\Users\Administrator\Documents\GitHub\v2-Docker\backups\ +- Dateiformat: backup_v2docker_YYYYMMDD_HHMMSS_encrypted.sql.gz.enc +- APScheduler für automatische Backups +- pg_dump/psql für Datenbank-Operationen +- Audit-Log für alle Backup-Aktionen +- Sicherheitsabfrage bei Wiederherstellung \ No newline at end of file diff --git a/v2/.env b/v2/.env index 03e39de..ff5f0e8 100644 --- a/v2/.env +++ b/v2/.env @@ -39,3 +39,11 @@ ADMIN_PANEL_DOMAIN=admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com # Serverseitig gepflegte aktuelle Software-Version # Diese wird vom Lizenzserver genutzt, um die Kundenversion zu vergleichen LATEST_CLIENT_VERSION=1.0.0 + +# ===================== BACKUP KONFIGURATION ===================== + +# E-Mail für Backup-Benachrichtigungen +EMAIL_ENABLED=false + +# Backup-Verschlüsselung (optional, wird automatisch generiert wenn leer) +# BACKUP_ENCRYPTION_KEY= diff --git a/v2/cookies.txt b/v2/cookies.txt deleted file mode 100644 index 0dbc954..0000000 --- a/v2/cookies.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_localhost FALSE / FALSE 1751984506 session NYjsxmcb81rBYjc17nkaARMbfQAgYxkTegLkLrxWfmM diff --git a/v2_adminpanel/Dockerfile b/v2_adminpanel/Dockerfile index fa620d5..cb9b123 100644 --- a/v2_adminpanel/Dockerfile +++ b/v2_adminpanel/Dockerfile @@ -7,6 +7,16 @@ ENV PYTHONIOENCODING=utf-8 WORKDIR /app +# System-Dependencies inkl. PostgreSQL-Tools installieren +RUN apt-get update && apt-get install -y \ + locales \ + postgresql-client \ + && sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \ + && locale-gen \ + && update-locale LANG=de_DE.UTF-8 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/v2_adminpanel/app.py b/v2_adminpanel/app.py index 97cbeed..1b777f8 100644 --- a/v2_adminpanel/app.py +++ b/v2_adminpanel/app.py @@ -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/", 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/") +@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') diff --git a/v2_adminpanel/init.sql b/v2_adminpanel/init.sql index a23ae53..cb2298c 100644 --- a/v2_adminpanel/init.sql +++ b/v2_adminpanel/init.sql @@ -49,3 +49,24 @@ CREATE TABLE IF NOT EXISTS audit_log ( CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC); CREATE INDEX idx_audit_log_username ON audit_log(username); CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id); + +-- Backup-Historie-Tabelle +CREATE TABLE IF NOT EXISTS backup_history ( + id SERIAL PRIMARY KEY, + filename TEXT NOT NULL, + filepath TEXT NOT NULL, + filesize BIGINT, + backup_type TEXT NOT NULL, -- 'manual' oder 'scheduled' + status TEXT NOT NULL, -- 'success', 'failed', 'in_progress' + error_message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by TEXT NOT NULL, + tables_count INTEGER, + records_count INTEGER, + duration_seconds NUMERIC, + is_encrypted BOOLEAN DEFAULT TRUE +); + +-- Index für bessere Performance +CREATE INDEX idx_backup_history_created_at ON backup_history(created_at DESC); +CREATE INDEX idx_backup_history_status ON backup_history(status); diff --git a/v2_adminpanel/requirements.txt b/v2_adminpanel/requirements.txt index 51bf61b..6c72e49 100644 --- a/v2_adminpanel/requirements.txt +++ b/v2_adminpanel/requirements.txt @@ -5,3 +5,5 @@ python-dotenv pyopenssl pandas openpyxl +cryptography +apscheduler diff --git a/v2_adminpanel/templates/audit_log.html b/v2_adminpanel/templates/audit_log.html index 545f838..bb515f1 100644 --- a/v2_adminpanel/templates/audit_log.html +++ b/v2_adminpanel/templates/audit_log.html @@ -47,6 +47,7 @@ 📋 Lizenzen 👥 Kunden 🟢 Sessions + 💾 Backups diff --git a/v2_adminpanel/templates/backups.html b/v2_adminpanel/templates/backups.html new file mode 100644 index 0000000..86a066f --- /dev/null +++ b/v2_adminpanel/templates/backups.html @@ -0,0 +1,286 @@ + + + + + Backup-Verwaltung - Admin Panel + + + + + + +
+
+

💾 Backup-Verwaltung

+
+ 📊 Dashboard + 📋 Lizenzen + 👥 Kunden + 🟢 Sessions + 📋 Audit +
+
+ + +
+
+
+
+
📅 Letztes erfolgreiches Backup
+ {% if last_backup %} +

Zeitpunkt: {{ last_backup[0].strftime('%d.%m.%Y %H:%M:%S') }}

+

Größe: {{ (last_backup[1] / 1024 / 1024)|round(2) }} MB

+

Dauer: {{ last_backup[2]|round(1) }} Sekunden

+ {% else %} +

Noch kein Backup vorhanden

+ {% endif %} +
+
+
+
+
+
+
🔧 Backup-Aktionen
+ +

+ Automatische Backups: Täglich um 03:00 Uhr +

+
+
+
+
+ + +
+
+
📋 Backup-Historie
+
+
+
+ + + + + + + + + + + + + + + {% for backup in backups %} + + + + + + + + + + + {% endfor %} + +
ZeitstempelDateinameGrößeTypStatusErstellt vonDetailsAktionen
{{ backup[6].strftime('%d.%m.%Y %H:%M:%S') }} + {{ backup[1] }} + {% if backup[11] %} + 🔒 Verschlüsselt + {% endif %} + + {% if backup[2] %} + {{ (backup[2] / 1024 / 1024)|round(2) }} MB + {% else %} + - + {% endif %} + + {% if backup[3] == 'manual' %} + Manuell + {% else %} + Automatisch + {% endif %} + + {% if backup[4] == 'success' %} + ✅ Erfolgreich + {% elif backup[4] == 'failed' %} + ❌ Fehlgeschlagen + {% else %} + ⏳ In Bearbeitung + {% endif %} + {{ backup[7] }} + {% if backup[8] and backup[9] %} + + {{ backup[8] }} Tabellen
+ {{ backup[9] }} Datensätze
+ {% if backup[10] %} + {{ backup[10]|round(1) }}s + {% endif %} +
+ {% else %} + - + {% endif %} +
+ {% if backup[4] == 'success' %} +
+ + 📥 Download + + +
+ {% endif %} +
+ + {% if not backups %} +
+

Noch keine Backups vorhanden.

+
+ {% endif %} +
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/v2_adminpanel/templates/customers.html b/v2_adminpanel/templates/customers.html index 98b485b..85c459d 100644 --- a/v2_adminpanel/templates/customers.html +++ b/v2_adminpanel/templates/customers.html @@ -25,6 +25,7 @@ 📋 Lizenzen 🟢 Sessions 📋 Audit + 💾 Backups
@@ -117,6 +118,42 @@ + +
+
+
+
+
💾 Backup-Status
+ {% if stats.last_backup %} + {% if stats.last_backup[4] == 'success' %} +

+ Letztes Backup: + ✅ Erfolgreich + am {{ stats.last_backup[0].strftime('%d.%m.%Y %H:%M:%S') }} +

+

+ + Größe: {{ (stats.last_backup[1] / 1024 / 1024)|round(2) }} MB | + Dauer: {{ stats.last_backup[2]|round(1) }} Sekunden | + Typ: {{ 'Manuell' if stats.last_backup[3] == 'manual' else 'Automatisch' }} + +

+ {% else %} +

+ Letztes Backup: + ❌ Fehlgeschlagen + am {{ stats.last_backup[0].strftime('%d.%m.%Y %H:%M:%S') }} +

+ {% endif %} + {% else %} +

Noch kein Backup vorhanden

+ {% endif %} + Backup-Verwaltung → +
+
+
+
+
diff --git a/v2_adminpanel/templates/edit_customer.html b/v2_adminpanel/templates/edit_customer.html index 48230cc..19f6dc6 100644 --- a/v2_adminpanel/templates/edit_customer.html +++ b/v2_adminpanel/templates/edit_customer.html @@ -26,6 +26,7 @@ 👥 Kunden 🟢 Sessions 📋 Audit + 💾 Backups
diff --git a/v2_adminpanel/templates/edit_license.html b/v2_adminpanel/templates/edit_license.html index b2c2a02..8a5e1bd 100644 --- a/v2_adminpanel/templates/edit_license.html +++ b/v2_adminpanel/templates/edit_license.html @@ -26,6 +26,7 @@ 👥 Kunden 🟢 Sessions 📋 Audit + 💾 Backups diff --git a/v2_adminpanel/templates/index.html b/v2_adminpanel/templates/index.html index 930365e..2e9896d 100644 --- a/v2_adminpanel/templates/index.html +++ b/v2_adminpanel/templates/index.html @@ -25,6 +25,7 @@ 👥 Kunden 🟢 Sessions 📋 Audit + 💾 Backups diff --git a/v2_adminpanel/templates/licenses.html b/v2_adminpanel/templates/licenses.html index 86287da..13cf740 100644 --- a/v2_adminpanel/templates/licenses.html +++ b/v2_adminpanel/templates/licenses.html @@ -30,6 +30,7 @@ 👥 Kunden 🟢 Sessions 📋 Audit + 💾 Backups