diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0238830..a95b7a2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -63,7 +63,8 @@ "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"BACKUP|LOGIN_2FA_SUCCESS\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/app.py)", "Bash(sed:*)", "Bash(python:*)", - "Bash(awk:*)" + "Bash(awk:*)", + "Bash(./backup_before_cleanup.sh:*)" ], "deny": [] } diff --git a/JOURNAL.md b/JOURNAL.md index fc95379..be0c5f5 100644 --- a/JOURNAL.md +++ b/JOURNAL.md @@ -1,2732 +1,2732 @@ -# v2-Docker Projekt Journal - -## Letzte Änderungen (06.01.2025) - -### Gerätelimit-Feature implementiert -- **Datenbank-Schema erweitert**: - - Neue Spalte `device_limit` in `licenses` Tabelle (Standard: 3, Range: 1-10) - - Neue Tabelle `device_registrations` für Hardware-ID Tracking - - Indizes für Performance-Optimierung hinzugefügt - -- **UI-Anpassungen**: - - Einzellizenz-Formular: Dropdown für Gerätelimit (1-10 Geräte) - - Batch-Formular: Gerätelimit pro Lizenz auswählbar - - Lizenz-Bearbeitung: Gerätelimit änderbar - - Lizenz-Anzeige: Zeigt aktive Geräte (z.B. "💻 2/3") - -- **Backend-Änderungen**: - - Lizenz-Erstellung speichert device_limit - - Batch-Erstellung berücksichtigt device_limit - - Lizenz-Update kann device_limit ändern - - API-Endpoints liefern Geräteinformationen - -- **Migration**: - - Skript `migrate_device_limit.sql` erstellt - - Setzt device_limit = 3 für alle bestehenden Lizenzen - -### Vollständig implementiert: -✅ Device Management UI (Geräte pro Lizenz anzeigen/verwalten) -✅ Device Validation Logic (Prüfung bei Geräte-Registrierung) -✅ API-Endpoints für Geräte-Registrierung/Deregistrierung - -### API-Endpoints: -- `GET /api/license//devices` - Listet alle Geräte einer Lizenz -- `POST /api/license//register-device` - Registriert ein neues Gerät -- `POST /api/license//deactivate-device/` - Deaktiviert ein Gerät - -### Features: -- Geräte-Registrierung mit Hardware-ID Validierung -- Automatische Prüfung des Gerätelimits -- Reaktivierung deaktivierter Geräte möglich -- Geräte-Verwaltung UI mit Modal-Dialog -- Anzeige von Gerätename, OS, IP, Registrierungsdatum -- Admin kann Geräte manuell deaktivieren - ---- - -## Projektübersicht -Lizenzmanagement-System für Social Media Account-Erstellungssoftware mit Docker-basierter Architektur. - -### Technische Anforderungen -- **Lokaler Betrieb**: Docker mit 4GB RAM und 40GB Speicher -- **Internet-Zugriff**: - - Admin Panel: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com - - API Server: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com -- **Datenbank**: PostgreSQL mit 2 Admin-Usern -- **Ziel**: PoC für spätere VPS-Migration - ---- - -## Best Practices für Produktiv-Migration - -### Passwort-Management -Für die Migration auf Hetzner/VPS müssen die Credentials sicher verwaltet werden: - -1. **Environment Variables erstellen:** - ```bash - # .env.example (ins Git Repository) - POSTGRES_USER=changeme - POSTGRES_PASSWORD=changeme - POSTGRES_DB=changeme - SECRET_KEY=generate-a-secure-key - ADMIN_USER_1=changeme - ADMIN_PASS_1=changeme - ADMIN_USER_2=changeme - ADMIN_PASS_2=changeme - - # .env (NICHT ins Git, auf Server erstellen) - POSTGRES_USER=produktiv_user - POSTGRES_PASSWORD=sicheres_passwort_min_20_zeichen - POSTGRES_DB=v2docker_prod - SECRET_KEY=generierter_64_zeichen_key - # etc. - ``` - -2. **Sichere Passwörter generieren:** - - Mindestens 20 Zeichen - - Mix aus Groß-/Kleinbuchstaben, Zahlen, Sonderzeichen - - Verschiedene Passwörter für Dev/Staging/Prod - - Password-Generator verwenden (z.B. `openssl rand -base64 32`) - -3. **Erweiterte Sicherheit (Optional):** - - HashiCorp Vault für zentrale Secret-Verwaltung - - Docker Secrets (für Docker Swarm) - - Cloud-Lösungen: AWS Secrets Manager, Azure Key Vault - -4. **Wichtige Checkliste:** - - [ ] `.env` in `.gitignore` aufnehmen - - [ ] Neue Credentials für Produktion generieren - - [ ] Backup der Credentials an sicherem Ort - - [ ] Regelmäßige Passwort-Rotation planen - - [ ] Keine Default-Passwörter verwenden - ---- - -## Änderungsprotokoll - -### 2025-06-06 - Journal erstellt -- Initialer Projektstand dokumentiert -- Aufgabenliste priorisiert -- Technische Anforderungen festgehalten - -### 2025-06-06 - UTF-8 Support implementiert -- Flask App Konfiguration für UTF-8 hinzugefügt (JSON_AS_ASCII=False) -- PostgreSQL Verbindung mit UTF-8 client_encoding -- HTML Forms mit accept-charset="UTF-8" -- Dockerfile mit deutschen Locale-Einstellungen (de_DE.UTF-8) -- PostgreSQL Container mit UTF-8 Initialisierung -- init.sql mit SET client_encoding = 'UTF8' - -**Geänderte Dateien:** -- v2_adminpanel/app.py -- v2_adminpanel/templates/index.html -- v2_adminpanel/init.sql -- v2_adminpanel/Dockerfile -- v2/docker-compose.yaml - -**Nächster Test:** -- Container neu bauen und starten -- Kundennamen mit Umlauten testen (z.B. "Müller GmbH", "Björn Schäfer") -- Email mit Umlauten testen - -### 2025-06-06 - Lizenzübersicht implementiert -- Neue Route `/licenses` für Lizenzübersicht -- SQL-Query mit JOIN zwischen licenses und customers -- Status-Berechnung (aktiv, läuft bald ab, abgelaufen) -- Farbcodierung für verschiedene Status -- Navigation zwischen Lizenz erstellen und Übersicht - -**Neue Features:** -- Anzeige aller Lizenzen mit Kundeninformationen -- Status-Anzeige basierend auf Ablaufdatum -- Unterscheidung zwischen Voll- und Testversion -- Responsive Tabelle mit Bootstrap -- Link von Dashboard zur Übersicht und zurück - -**Geänderte/Neue Dateien:** -- v2_adminpanel/app.py (neue Route hinzugefügt) -- v2_adminpanel/templates/licenses.html (neu erstellt) -- v2_adminpanel/templates/index.html (Navigation ergänzt) - -**Nächster Test:** -- Container neu starten -- Mehrere Lizenzen mit verschiedenen Ablaufdaten erstellen -- Lizenzübersicht unter /licenses aufrufen - -### 2025-06-06 - Lizenz bearbeiten/löschen implementiert -- Neue Routen für Bearbeiten und Löschen von Lizenzen -- Bearbeitungsformular mit vorausgefüllten Werten -- Aktiv/Inaktiv-Status kann geändert werden -- Lösch-Bestätigung per JavaScript confirm() -- Kunde kann nicht geändert werden (nur Lizenzdetails) - -**Neue Features:** -- `/license/edit/` - Bearbeitungsformular -- `/license/delete/` - Lizenz löschen (POST) -- Aktionen-Spalte in der Lizenzübersicht -- Buttons für Bearbeiten und Löschen -- Checkbox für Aktiv-Status - -**Geänderte/Neue Dateien:** -- v2_adminpanel/app.py (edit_license und delete_license Routen) -- v2_adminpanel/templates/licenses.html (Aktionen-Spalte hinzugefügt) -- v2_adminpanel/templates/edit_license.html (neu erstellt) - -**Sicherheit:** -- Login-Required für alle Aktionen -- POST-only für Löschvorgänge -- Bestätigungsdialog vor dem Löschen - -### 2025-06-06 - Kundenverwaltung implementiert -- Komplette CRUD-Funktionalität für Kunden -- Übersicht zeigt Anzahl aktiver/gesamter Lizenzen pro Kunde -- Kunden können nur gelöscht werden, wenn sie keine Lizenzen haben -- Bearbeitungsseite zeigt alle Lizenzen des Kunden - -**Neue Features:** -- `/customers` - Kundenübersicht mit Statistiken -- `/customer/edit/` - Kunde bearbeiten (Name, E-Mail) -- `/customer/delete/` - Kunde löschen (nur ohne Lizenzen) -- Navigation zwischen allen drei Hauptbereichen -- Anzeige der Kundenlizenzen beim Bearbeiten - -**Geänderte/Neue Dateien:** -- v2_adminpanel/app.py (customers, edit_customer, delete_customer Routen) -- v2_adminpanel/templates/customers.html (neu erstellt) -- v2_adminpanel/templates/edit_customer.html (neu erstellt) -- v2_adminpanel/templates/index.html (Navigation erweitert) -- v2_adminpanel/templates/licenses.html (Navigation erweitert) - -**Besonderheiten:** -- Lösch-Button ist deaktiviert, wenn Kunde Lizenzen hat -- Aktive Lizenzen werden separat gezählt (nicht abgelaufen + aktiv) -- UTF-8 Support für Kundennamen mit Umlauten - -### 2025-06-06 - Dashboard mit Statistiken implementiert -- Übersichtliches Dashboard als neue Startseite -- Statistik-Karten mit wichtigen Kennzahlen -- Listen für bald ablaufende und zuletzt erstellte Lizenzen -- Routing angepasst: Dashboard (/) und Lizenz erstellen (/create) - -**Neue Features:** -- Statistik-Karten: Kunden, Lizenzen gesamt, Aktive, Ablaufende -- Aufteilung nach Lizenztypen (Vollversion/Testversion) -- Aufteilung nach Status (Aktiv/Abgelaufen) -- Top 10 bald ablaufende Lizenzen mit Restlaufzeit -- Letzte 5 erstellte Lizenzen mit Status -- Hover-Effekt auf Statistik-Karten -- Einheitliche Navigation mit Dashboard-Link - -**Geänderte/Neue Dateien:** -- v2_adminpanel/app.py (dashboard() komplett überarbeitet, create_license() Route) -- v2_adminpanel/templates/dashboard.html (neu erstellt) -- v2_adminpanel/templates/index.html (Navigation erweitert) -- v2_adminpanel/templates/licenses.html (Navigation angepasst) -- v2_adminpanel/templates/customers.html (Navigation angepasst) - -**Dashboard-Inhalte:** -- 4 Hauptstatistiken als Karten -- Lizenztyp-Verteilung -- Status-Verteilung -- Warnung für bald ablaufende Lizenzen -- Übersicht der neuesten Aktivitäten - -### 2025-06-06 - Suchfunktion implementiert -- Volltextsuche für Lizenzen und Kunden -- Case-insensitive Suche mit LIKE-Operator -- Suchergebnisse mit Hervorhebung des Suchbegriffs -- Suche zurücksetzen Button - -**Neue Features:** -- **Lizenzsuche**: Sucht in Lizenzschlüssel, Kundenname und E-Mail -- **Kundensuche**: Sucht in Kundenname und E-Mail -- Suchformular mit autofocus für schnelle Eingabe -- Anzeige des aktiven Suchbegriffs -- Unterschiedliche Meldungen für leere Ergebnisse - -**Geänderte Dateien:** -- v2_adminpanel/app.py (licenses() und customers() mit Suchlogik erweitert) -- v2_adminpanel/templates/licenses.html (Suchformular hinzugefügt) -- v2_adminpanel/templates/customers.html (Suchformular hinzugefügt) - -**Technische Details:** -- GET-Parameter für Suche -- SQL LIKE mit LOWER() für Case-Insensitive Suche -- Wildcards (%) für Teilstring-Suche -- UTF-8 kompatibel für deutsche Umlaute - -### 2025-06-06 - Filter und Pagination implementiert -- Erweiterte Filteroptionen für Lizenzübersicht -- Pagination für große Datenmengen (20 Einträge pro Seite) -- Filter bleiben bei Seitenwechsel erhalten - -**Neue Features für Lizenzen:** -- **Filter nach Typ**: Alle, Vollversion, Testversion -- **Filter nach Status**: Alle, Aktiv, Läuft bald ab, Abgelaufen, Deaktiviert -- **Kombinierbar mit Suche**: Filter und Suche funktionieren zusammen -- **Pagination**: Navigation durch mehrere Seiten -- **Ergebnisanzeige**: Zeigt Anzahl gefilterter Ergebnisse - -**Neue Features für Kunden:** -- **Pagination**: 20 Kunden pro Seite -- **Seitennavigation**: Erste, Letzte, Vor, Zurück -- **Kombiniert mit Suche**: Suchparameter bleiben erhalten - -**Geänderte Dateien:** -- v2_adminpanel/app.py (licenses() und customers() mit Filter/Pagination erweitert) -- v2_adminpanel/templates/licenses.html (Filter-Formular und Pagination hinzugefügt) -- v2_adminpanel/templates/customers.html (Pagination hinzugefügt) - -**Technische Details:** -- SQL WHERE-Klauseln für Filter -- LIMIT/OFFSET für Pagination -- URL-Parameter bleiben bei Navigation erhalten -- Responsive Bootstrap-Komponenten - -### 2025-06-06 - Session-Tracking implementiert -- Neue Tabelle für Session-Verwaltung -- Anzeige aktiver und beendeter Sessions -- Manuelles Beenden von Sessions möglich -- Dashboard zeigt Anzahl aktiver Sessions - -**Neue Features:** -- **Sessions-Tabelle**: Speichert Session-ID, IP, User-Agent, Zeitstempel -- **Aktive Sessions**: Zeigt alle laufenden Sessions mit Inaktivitätszeit -- **Session-Historie**: Letzte 24 Stunden beendeter Sessions -- **Session beenden**: Admins können Sessions manuell beenden -- **Farbcodierung**: Grün (aktiv), Gelb (>5 Min inaktiv), Rot (lange inaktiv) - -**Geänderte/Neue Dateien:** -- v2_adminpanel/init.sql (sessions Tabelle hinzugefügt) -- v2_adminpanel/app.py (sessions() und end_session() Routen) -- v2_adminpanel/templates/sessions.html (neu erstellt) -- v2_adminpanel/templates/dashboard.html (Session-Statistik) -- Alle Templates (Session-Navigation hinzugefügt) - -**Technische Details:** -- Heartbeat-basiertes Tracking (last_heartbeat) -- Automatische Inaktivitätsberechnung -- Session-Dauer Berechnung -- Responsive Tabellen mit Bootstrap - -**Hinweis:** -Die Session-Daten werden erst gefüllt, wenn der License Server API implementiert ist und Clients sich verbinden. - -### 2025-06-06 - Export-Funktion implementiert -- CSV und Excel Export für Lizenzen und Kunden -- Formatierte Ausgabe mit deutschen Datumsformaten -- UTF-8 Unterstützung für Sonderzeichen - -**Neue Features:** -- **Lizenz-Export**: Alle Lizenzen mit Kundeninformationen -- **Kunden-Export**: Alle Kunden mit Lizenzstatistiken -- **Format-Optionen**: Excel (.xlsx) und CSV (.csv) -- **Deutsche Formatierung**: Datum als dd.mm.yyyy, Status auf Deutsch -- **UTF-8 Export**: Korrekte Kodierung für Umlaute -- **Export-Buttons**: Dropdown-Menüs in Lizenz- und Kundenübersicht - -**Geänderte Dateien:** -- v2_adminpanel/app.py (export_licenses() und export_customers() Routen) -- v2_adminpanel/requirements.txt (pandas und openpyxl hinzugefügt) -- v2_adminpanel/templates/licenses.html (Export-Dropdown hinzugefügt) -- v2_adminpanel/templates/customers.html (Export-Dropdown hinzugefügt) - -**Technische Details:** -- Pandas für Datenverarbeitung -- OpenPyXL für Excel-Export -- CSV mit Semikolon-Trennung für deutsche Excel-Kompatibilität -- Automatische Spaltenbreite in Excel -- BOM für UTF-8 CSV (Excel-Kompatibilität) - -### 2025-06-06 - Audit-Log implementiert -- Vollständiges Änderungsprotokoll für alle Aktionen -- Filterbare Übersicht mit Pagination -- Detaillierte Anzeige von Änderungen - -**Neue Features:** -- **Audit-Log-Tabelle**: Speichert alle Änderungen mit Zeitstempel, Benutzer, IP -- **Protokollierte Aktionen**: CREATE, UPDATE, DELETE, LOGIN, LOGOUT, EXPORT -- **JSON-Speicherung**: Alte und neue Werte als JSONB für flexible Abfragen -- **Filter-Optionen**: Nach Benutzer, Aktion und Entität -- **Detail-Anzeige**: Aufklappbare Änderungsdetails -- **Navigation**: Audit-Link in allen Templates - -**Geänderte/Neue Dateien:** -- v2_adminpanel/init.sql (audit_log Tabelle mit Indizes) -- v2_adminpanel/app.py (log_audit() Funktion und audit_log() Route) -- v2_adminpanel/templates/audit_log.html (neu erstellt) -- Alle Templates (Audit-Navigation hinzugefügt) - -**Technische Details:** -- JSONB für strukturierte Datenspeicherung -- Performance-Indizes auf timestamp, username und entity -- Farbcodierung für verschiedene Aktionen -- 50 Einträge pro Seite mit Pagination -- IP-Adresse und User-Agent Tracking - -### 2025-06-06 - PostgreSQL UTF-8 Locale konfiguriert -- Eigenes PostgreSQL Dockerfile für deutsche Locale -- Sicherstellung der UTF-8 Unterstützung auf Datenbankebene - -**Neue Features:** -- **PostgreSQL Dockerfile**: Installiert deutsche Locale (de_DE.UTF-8) -- **Locale-Umgebungsvariablen**: LANG, LANGUAGE, LC_ALL gesetzt -- **Docker Compose Update**: Verwendet jetzt eigenes PostgreSQL-Image - -**Neue Dateien:** -- v2_postgres/Dockerfile (neu erstellt) - -**Geänderte Dateien:** -- v2/docker-compose.yaml (postgres Service nutzt jetzt build statt image) - -**Technische Details:** -- 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 - -### 2025-06-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 - -### 2025-06-07 - HTTPS/SSL und Internet-Zugriff implementiert -- Nginx Reverse Proxy für externe Erreichbarkeit eingerichtet -- SSL-Zertifikate von IONOS mit vollständiger Certificate Chain integriert -- Netzwerkkonfiguration für feste IP-Adresse -- DynDNS und Port-Forwarding konfiguriert - -**Neue Features:** -- **Nginx Reverse Proxy**: Leitet HTTPS-Anfragen an Container weiter -- **SSL-Zertifikate**: Wildcard-Zertifikat von IONOS für *.z5m7q9dk3ah2v1plx6ju.com -- **Certificate Chain**: Server-, Intermediate- und Root-Zertifikate kombiniert -- **Subdomain-Routing**: admin-panel-undso und api-software-undso -- **Port-Forwarding**: FRITZ!Box 443 → 192.168.178.88 -- **Feste IP**: Windows-PC auf 192.168.178.88 konfiguriert - -**Neue/Geänderte Dateien:** -- v2_nginx/nginx.conf (Reverse Proxy Konfiguration) -- v2_nginx/Dockerfile (Nginx Container mit SSL) -- v2_nginx/ssl/fullchain.pem (Certificate Chain) -- v2_nginx/ssl/privkey.pem (Private Key) -- v2/docker-compose.yaml (nginx Service hinzugefügt) -- set-static-ip.ps1 (PowerShell Script für feste IP) -- reset-to-dhcp.ps1 (PowerShell Script für DHCP) - -**Technische Details:** -- SSL-Termination am Nginx Reverse Proxy -- Backend-Kommunikation über Docker-internes Netzwerk -- Admin-Panel nur noch über Nginx erreichbar (Port 443 nicht mehr exposed) -- License-Server behält externen Port 8443 für direkte API-Zugriffe -- Intermediate Certificates aus ZIP extrahiert und korrekt verkettet - -**Zugangsdaten:** -- Admin-Panel: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com -- Benutzer 1: rac00n -- Benutzer 2: w@rh@mm3r - -**Status:** -- ✅ Admin-Panel extern erreichbar ohne SSL-Warnungen -- ✅ Reverse Proxy funktioniert -- ✅ SSL-Zertifikate korrekt konfiguriert -- ✅ Netzwerk-Setup abgeschlossen - -### 2025-06-07 - Projekt-Cleanup durchgeführt -- Redundante und überflüssige Dateien entfernt -- Projektstruktur verbessert und organisiert - -**Durchgeführte Änderungen:** -1. **Entfernte Dateien:** - - v2_adminpanel/templates/.env (Duplikat der Haupt-.env) - - v2_postgreSQL/ (leeres Verzeichnis) - - SSL-Zertifikate aus Root-Verzeichnis (7 Dateien) - - Ungenutzer `json` Import aus app.py - -2. **Organisatorische Verbesserungen:** - - PowerShell-Scripts in neuen `scripts/` Ordner verschoben - - SSL-Zertifikate nur noch in v2_nginx/ssl/ - - Keine Konfigurationsdateien mehr in Template-Verzeichnissen - -**Technische Details:** -- Docker-Container wurden gestoppt und nach Cleanup neu gestartet -- Alle Services laufen wieder normal -- Keine funktionalen Änderungen, nur Struktur-Verbesserungen - -**Ergebnis:** -- Verbesserte Projektstruktur -- Erhöhte Sicherheit (keine SSL-Zertifikate im Root) -- Klarere Dateiorganisation - -### 2025-06-07 - SSL "Nicht sicher" Problem behoben -- Chrome-Warnung trotz gültigem Zertifikat analysiert und behoben -- Ursache: Selbstsigniertes Zertifikat in der Admin Panel Flask-App - -**Durchgeführte Änderungen:** -1. **Admin Panel Konfiguration (app.py):** - - Von HTTPS mit selbstsigniertem Zertifikat auf HTTP Port 5000 umgestellt - - `ssl_context='adhoc'` entfernt - - Flask läuft jetzt auf `0.0.0.0:5000` statt HTTPS - -2. **Dockerfile Anpassung (v2_adminpanel/Dockerfile):** - - EXPOSE Port von 443 auf 5000 geändert - - Container exponiert jetzt HTTP statt HTTPS - -3. **Nginx Konfiguration (nginx.conf):** - - proxy_pass von `https://admin-panel:443` auf `http://admin-panel:5000` geändert - - `proxy_ssl_verify off` entfernt (nicht mehr benötigt) - - Sicherheits-Header für beide Domains hinzugefügt: - - Strict-Transport-Security (HSTS) - erzwingt HTTPS für 1 Jahr - - X-Content-Type-Options - verhindert MIME-Type Sniffing - - X-Frame-Options - Schutz vor Clickjacking - - X-XSS-Protection - aktiviert XSS-Filter - - Referrer-Policy - kontrolliert Referrer-Informationen - -**Technische Details:** -- Externer Traffic nutzt weiterhin HTTPS mit gültigen IONOS-Zertifikaten -- Interne Kommunikation zwischen Nginx und Admin Panel läuft über HTTP (sicher im Docker-Netzwerk) -- Kein selbstsigniertes Zertifikat mehr in der Zertifikatskette -- SSL-Termination erfolgt ausschließlich am Nginx Reverse Proxy - -**Docker Neustart:** -- Container gestoppt (`docker-compose down`) -- Images neu gebaut (`docker-compose build`) -- Container neu gestartet (`docker-compose up -d`) -- Alle Services laufen normal - -**Ergebnis:** -- ✅ "Nicht sicher" Warnung in Chrome behoben -- ✅ Saubere SSL-Konfiguration ohne Mixed Content -- ✅ Verbesserte Sicherheits-Header implementiert -- ✅ Admin Panel zeigt jetzt grünes Schloss-Symbol - -### 2025-06-07 - Sicherheitslücke geschlossen: License Server Port -- Direkter Zugriff auf License Server Port 8443 entfernt -- Sicherheitsanalyse der exponierten Ports durchgeführt - -**Identifiziertes Problem:** -- License Server war direkt auf Port 8443 von außen erreichbar -- Umging damit die Nginx-Sicherheitsschicht und Security Headers -- Besonders kritisch, da nur Platzhalter ohne echte Sicherheit - -**Durchgeführte Änderung:** -- Port-Mapping für License Server in docker-compose.yaml entfernt -- Service ist jetzt nur noch über Nginx Reverse Proxy erreichbar -- Gleiche Sicherheitskonfiguration wie Admin Panel - -**Aktuelle Port-Exposition:** -- ✅ Nginx: Port 80/443 (benötigt für externen Zugriff) -- ✅ PostgreSQL: Keine Ports exponiert (gut) -- ✅ Admin Panel: Nur über Nginx erreichbar -- ✅ License Server: Nur über Nginx erreichbar (vorher direkt auf 8443) - -**Weitere identifizierte Sicherheitsthemen:** -1. Credentials im Klartext in .env Datei -2. SSL-Zertifikate im Repository gespeichert -3. License Server noch nicht implementiert - -**Empfehlung:** Docker-Container neu starten für Änderungsübernahme - -### 2025-06-07 - License Server Port 8443 wieder aktiviert -- Port 8443 für direkten Zugriff auf License Server wieder geöffnet -- Notwendig für Client-Software Lizenzprüfung - -**Begründung:** -- Client-Software benötigt direkten Zugriff für Lizenzprüfung -- Umgehung von möglichen Firewall-Blockaden auf Port 443 -- Weniger Latenz ohne Nginx-Proxy -- Flexibilität für verschiedene Client-Implementierungen - -**Konfiguration:** -- License Server erreichbar über: - - Direkt: Port 8443 (für Client-Software) - - Via Nginx: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com (für Browser/Tests) - -**Sicherheitshinweis:** -- Port 8443 ist wieder direkt exponiert -- License Server muss vor Produktivbetrieb implementiert werden mit: - - Eigener SSL-Konfiguration - - API-Key Authentifizierung - - Rate Limiting - - Input-Validierung - -**Status:** -- Port-Mapping in docker-compose.yaml wiederhergestellt -- Änderung erfordert Docker-Neustart - -### 2025-06-07 - Rate-Limiting und Brute-Force-Schutz implementiert -- Umfassender Schutz vor Login-Angriffen mit IP-Sperre -- Dashboard-Integration für Sicherheitsüberwachung - -**Implementierte Features:** -1. **Rate-Limiting System:** - - 5 Login-Versuche erlaubt, danach 24h IP-Sperre - - Progressive Fehlermeldungen (zufällig aus 5 lustigen Varianten) - - CAPTCHA nach 2 Fehlversuchen (Google reCAPTCHA v2 vorbereitet) - - E-Mail-Benachrichtigung bei Sperrung (vorbereitet, deaktiviert für PoC) - -2. **Timing-Attack Schutz:** - - Mindestens 1 Sekunde Antwortzeit bei allen Login-Versuchen - - Gleiche Antwortzeit bei richtigem/falschem Username - - Verhindert Username-Enumeration - -3. **Lustige Fehlermeldungen (zufällig):** - - "NOPE!" - - "ACCESS DENIED, TRY HARDER" - - "WRONG! 🚫" - - "COMPUTER SAYS NO" - - "YOU FAILED" - -4. **Dashboard-Sicherheitswidget:** - - Sicherheitslevel-Anzeige (NORMAL/ERHÖHT/KRITISCH) - - Anzahl gesperrter IPs - - Fehlversuche heute - - Letzte 5 Sicherheitsereignisse mit Details - -5. **IP-Verwaltung:** - - Übersicht aller gesperrten IPs - - Manuelles Entsperren möglich - - Login-Versuche zurücksetzen - - Detaillierte Informationen pro IP - -6. **Audit-Log Erweiterungen:** - - LOGIN_SUCCESS - Erfolgreiche Anmeldung - - LOGIN_FAILED - Fehlgeschlagener Versuch - - LOGIN_BLOCKED - IP wurde gesperrt - - UNBLOCK_IP - IP manuell entsperrt - - CLEAR_ATTEMPTS - Versuche zurückgesetzt - -**Neue/Geänderte Dateien:** -- v2_adminpanel/init.sql (login_attempts Tabelle) -- v2_adminpanel/app.py (Rate-Limiting Logik, neue Routen) -- v2_adminpanel/templates/login.html (Fehlermeldungs-Styling, CAPTCHA) -- v2_adminpanel/templates/dashboard.html (Sicherheitswidget) -- v2_adminpanel/templates/blocked_ips.html (neu - IP-Verwaltung) - -**Technische Details:** -- IP-Ermittlung berücksichtigt Proxy-Header (X-Forwarded-For) -- Fehlermeldungen mit Animation (shake-effect) -- Farbcodierung: Rot für Fehler, Lila für Sperre, Orange für CAPTCHA -- Automatische Bereinigung alter Einträge möglich - -**Sicherheitsverbesserungen:** -- Schutz vor Brute-Force-Angriffen -- Timing-Attack-Schutz implementiert -- IP-basierte Sperrung für 24 Stunden -- Audit-Trail für alle Sicherheitsereignisse - -**Hinweis für Produktion:** -- CAPTCHA-Keys müssen in .env konfiguriert werden -- E-Mail-Server für Benachrichtigungen einrichten -- Rate-Limits können über Konstanten angepasst werden - -### 2025-06-07 - Session-Timeout mit Live-Timer implementiert -- 5 Minuten Inaktivitäts-Timeout mit visueller Countdown-Anzeige -- Automatische Session-Verlängerung bei Benutzeraktivität - -**Implementierte Features:** -1. **Session-Timeout Backend:** - - Flask Session-Timeout auf 5 Minuten konfiguriert - - Heartbeat-Endpoint für Keep-Alive - - Automatisches Session-Update bei jeder Aktion - -2. **Live-Timer in der Navbar:** - - Countdown von 5:00 bis 0:00 - - Position: Zwischen Logo und Username - - Farbwechsel nach verbleibender Zeit: - - Grün: > 2 Minuten - - Gelb: 1-2 Minuten - - Rot: < 1 Minute - - Blinkend: < 30 Sekunden - -3. **Benutzerinteraktion:** - - Timer wird bei jeder Aktivität zurückgesetzt - - Tracking von: Klicks, Tastatureingaben, Mausbewegungen - - Automatischer Heartbeat bei Aktivität - - Warnung bei < 1 Minute mit "Session verlängern" Button - -4. **Base-Template System:** - - Neue base.html als Basis für alle Admin-Seiten - - Alle Templates (außer login.html) nutzen jetzt base.html - - Einheitliches Layout und Timer auf allen Seiten - -**Neue/Geänderte Dateien:** -- v2_adminpanel/app.py (Session-Konfiguration, Heartbeat-Endpoint) -- v2_adminpanel/templates/base.html (neu - Base-Template mit Timer) -- Alle anderen Templates aktualisiert für Template-Vererbung - -**Technische Details:** -- JavaScript-basierter Countdown-Timer -- AJAX-Heartbeat alle 5 Sekunden bei Aktivität -- LocalStorage für Tab-Synchronisation möglich -- Automatischer Logout bei 0:00 -- Fetch-Interceptor für automatische Session-Verlängerung - -**Sicherheitsverbesserung:** -- Automatischer Logout nach 5 Minuten Inaktivität -- Verhindert vergessene Sessions -- Visuelles Feedback für Session-Status - -### 2025-06-07 - Session-Timeout Bug behoben -- Problem: Session-Timeout funktionierte nicht korrekt - Session blieb länger als 5 Minuten aktiv -- Ursache: login_required Decorator aktualisierte last_activity bei JEDEM Request - -**Durchgeführte Änderungen:** -1. **login_required Decorator (app.py):** - - Prüft jetzt ob Session abgelaufen ist (5 Minuten seit last_activity) - - Aktualisiert last_activity NICHT mehr automatisch - - Führt AUTO_LOGOUT mit Audit-Log bei Timeout durch - - Speichert Username vor session.clear() für korrektes Logging - -2. **Heartbeat-Endpoint (app.py):** - - Geändert zu POST-only Endpoint - - Aktualisiert explizit last_activity wenn aufgerufen - - Wird nur bei aktiver Benutzerinteraktion aufgerufen - -3. **Frontend Timer (base.html):** - - Heartbeat wird als POST Request gesendet - - trackActivity() ruft extendSession() ohne vorheriges resetTimer() auf - - Timer wird erst nach erfolgreichem Heartbeat zurückgesetzt - - AJAX Interceptor ignoriert Heartbeat-Requests - -4. **Audit-Log Erweiterung:** - - Neue Aktion AUTO_LOGOUT hinzugefügt - - Orange Farbcodierung (#fd7e14) - - Zeigt Grund des Timeouts im Audit-Log - -**Ergebnis:** -- ✅ Session läuft nach exakt 5 Minuten Inaktivität ab -- ✅ Benutzeraktivität verlängert Session korrekt -- ✅ AUTO_LOGOUT wird im Audit-Log protokolliert -- ✅ Visueller Timer zeigt verbleibende Zeit - -### 2025-06-07 - Session-Timeout weitere Verbesserungen -- Zusätzliche Fixes nach Test-Feedback implementiert - -**Weitere durchgeführte Änderungen:** -1. **Fehlender Import behoben:** - - `flash` zu Flask-Imports hinzugefügt für Timeout-Warnmeldungen - -2. **Session-Cookie-Konfiguration erweitert (app.py):** - - SESSION_COOKIE_HTTPONLY = True (Sicherheit gegen XSS) - - SESSION_COOKIE_SECURE = False (intern HTTP, extern HTTPS via Nginx) - - SESSION_COOKIE_SAMESITE = 'Lax' (CSRF-Schutz) - - SESSION_COOKIE_NAME = 'admin_session' (eindeutiger Name) - - SESSION_REFRESH_EACH_REQUEST = False (verhindert automatische Verlängerung) - -3. **Session-Handling verbessert:** - - Entfernt: session.permanent = True aus login_required decorator - - Hinzugefügt: session.modified = True im Heartbeat für explizites Speichern - - Debug-Logging für Session-Timeout-Prüfung hinzugefügt - -4. **Nginx-Konfiguration:** - - Bereits korrekt konfiguriert für Heartbeat-Weiterleitung - - Proxy-Headers für korrekte IP-Weitergabe - -**Technische Details:** -- Flask-Session mit Filesystem-Backend nutzt jetzt korrekte Cookie-Einstellungen -- Session-Cookie wird nicht mehr automatisch bei jedem Request verlängert -- Explizite Session-Modifikation nur bei Heartbeat-Requests -- Debug-Logs zeigen Zeit seit letzter Aktivität für Troubleshooting - -**Status:** -- ✅ Session-Timeout-Mechanismus vollständig implementiert -- ✅ Debug-Logging für Session-Überwachung aktiv -- ✅ Cookie-Sicherheitseinstellungen optimiert - -### 2025-06-07 - CAPTCHA Backend-Validierung implementiert -- Google reCAPTCHA v2 Backend-Verifizierung hinzugefügt - -**Implementierte Features:** -1. **verify_recaptcha() Funktion (app.py):** - - Validiert CAPTCHA-Response mit Google API - - Fallback: Wenn RECAPTCHA_SECRET_KEY nicht konfiguriert, wird CAPTCHA übersprungen (für PoC) - - Timeout von 5 Sekunden für API-Request - - Error-Handling für Netzwerkfehler - - Logging für Debugging und Fehleranalyse - -2. **Login-Route Erweiterungen:** - - CAPTCHA wird nach 2 Fehlversuchen angezeigt - - Prüfung ob CAPTCHA-Response vorhanden - - Validierung der CAPTCHA-Response gegen Google API - - Unterschiedliche Fehlermeldungen für fehlende/ungültige CAPTCHA - - Site Key wird aus Environment-Variable an Template übergeben - -3. **Environment-Konfiguration (.env):** - - RECAPTCHA_SITE_KEY (für Frontend) - - RECAPTCHA_SECRET_KEY (für Backend-Validierung) - - Beide auskommentiert für PoC-Phase - -4. **Dependencies:** - - requests Library zu requirements.txt hinzugefügt - -**Sicherheitsaspekte:** -- CAPTCHA verhindert automatisierte Brute-Force-Angriffe -- Timing-Attack-Schutz bleibt auch bei CAPTCHA-Prüfung aktiv -- Bei Netzwerkfehlern wird CAPTCHA als bestanden gewertet (Verfügbarkeit vor Sicherheit) -- Secret Key wird niemals im Frontend exponiert - -**Verwendung:** -1. Google reCAPTCHA v2 Keys erstellen: https://www.google.com/recaptcha/admin -2. Keys in .env eintragen: - ``` - RECAPTCHA_SITE_KEY=your-site-key - RECAPTCHA_SECRET_KEY=your-secret-key - ``` -3. Container neu starten - -**Status:** -- ✅ CAPTCHA-Frontend bereits vorhanden (login.html) -- ✅ Backend-Validierung vollständig implementiert -- ✅ Fallback für PoC-Betrieb ohne Google-Keys -- ✅ Integration in Rate-Limiting-System -- ⚠️ CAPTCHA-Keys noch nicht konfiguriert (für PoC deaktiviert) - -**Anleitung für Google reCAPTCHA Keys:** - -1. **Registrierung bei Google reCAPTCHA:** - - Gehe zu: https://www.google.com/recaptcha/admin/create - - Melde dich mit Google-Konto an - - Label eingeben: "v2-Docker Admin Panel" - - Typ wählen: "reCAPTCHA v2" → "Ich bin kein Roboter"-Kästchen - - Domains hinzufügen: - ``` - admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com - localhost - ``` - - Nutzungsbedingungen akzeptieren - - Senden klicken - -2. **Keys erhalten:** - - Site Key (öffentlich für Frontend) - - Secret Key (geheim für Backend-Validierung) - -3. **Keys in .env eintragen:** - ```bash - RECAPTCHA_SITE_KEY=6Ld... - RECAPTCHA_SECRET_KEY=6Ld... - ``` - -4. **Container neu starten:** - ```bash - docker-compose down - docker-compose up -d - ``` - -**Kosten:** -- Kostenlos bis 1 Million Anfragen pro Monat -- Danach: $1.00 pro 1000 zusätzliche Anfragen -- Für dieses Projekt reicht die kostenlose Version vollkommen aus - -**Test-Keys für Entwicklung:** -- Site Key: `6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI` -- Secret Key: `6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe` -- ⚠️ Diese Keys nur für lokale Tests verwenden, niemals produktiv! - -**Aktueller Status:** -- Code ist vollständig implementiert und getestet -- CAPTCHA wird nach 2 Fehlversuchen angezeigt -- Ohne konfigurierte Keys wird CAPTCHA-Prüfung übersprungen -- Für Produktion müssen nur die Keys in .env eingetragen werden - -### 2025-06-07 - License Key Generator implementiert -- Automatische Generierung von Lizenzschlüsseln mit definiertem Format - -**Implementiertes Format:** -`AF-YYYYMMFT-XXXX-YYYY-ZZZZ` -- **AF** = Account Factory (feste Produktkennung) -- **YYYY** = Jahr (z.B. 2025) -- **MM** = Monat (z.B. 06) -- **FT** = Lizenztyp (F=Fullversion, T=Testversion) -- **XXXX-YYYY-ZZZZ** = Zufällige alphanumerische Zeichen (ohne verwirrende wie 0/O, 1/I/l) - -**Beispiele:** -- Vollversion: `AF-202506F-A7K9-M3P2-X8R4` -- Testversion: `AF-202512T-B2N5-K8L3-Q9W7` - -**Implementierte Features:** - -1. **Backend-Funktionen (app.py):** - - `generate_license_key()` - Generiert Keys mit kryptografisch sicherem Zufallsgenerator - - `validate_license_key()` - Validiert das Key-Format mit Regex - - Verwendet `secrets` statt `random` für Sicherheit - - Erlaubte Zeichen: ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (ohne verwirrende) - -2. **API-Endpoint:** - - POST `/api/generate-license-key` - JSON API für Key-Generierung - - Prüft auf Duplikate in der Datenbank (max. 10 Versuche) - - Audit-Log-Eintrag bei jeder Generierung - - Login-Required geschützt - -3. **Frontend-Verbesserungen (index.html):** - - Generate-Button neben License Key Input - - Placeholder und Pattern-Attribut für Format-Hinweis - - Auto-Uppercase bei manueller Eingabe - - Visuelles Feedback bei erfolgreicher Generierung - - Format-Hinweis unter dem Eingabefeld - -4. **JavaScript-Features:** - - AJAX-basierte Key-Generierung ohne Seiten-Reload - - Automatische Prüfung bei Lizenztyp-Änderung - - Ladeindikator während der Generierung - - Fehlerbehandlung mit Benutzer-Feedback - - Standard-Datum-Einstellungen (heute + 1 Jahr) - -5. **Validierung:** - - Server-seitige Format-Validierung beim Speichern - - Flash-Message bei ungültigem Format - - Automatische Großschreibung des Keys - - Pattern-Validierung im HTML-Formular - -6. **Weitere Fixes:** - - Form Action von "/" auf "/create" korrigiert - - Flash-Messages mit Bootstrap Toasts implementiert - - GENERATE_KEY Aktion zum Audit-Log hinzugefügt (Farbe: #20c997) - -**Technische Details:** -- Keine vorhersagbaren Muster durch `secrets.choice()` -- Datum im Key zeigt Erstellungszeitpunkt -- Lizenztyp direkt im Key erkennbar -- Kollisionsprüfung gegen Datenbank - -**Status:** -- ✅ Backend-Generierung vollständig implementiert -- ✅ Frontend mit Generate-Button und JavaScript -- ✅ Validierung und Fehlerbehandlung -- ✅ Audit-Log-Integration -- ✅ Form-Action-Bug behoben - -### 2025-06-07 - Batch-Lizenzgenerierung implementiert -- Mehrere Lizenzen auf einmal für einen Kunden erstellen - -**Implementierte Features:** - -1. **Batch-Formular (/batch):** - - Kunde und E-Mail eingeben - - Anzahl der Lizenzen (1-100) - - Lizenztyp (Vollversion/Testversion) - - Gültigkeitszeitraum für alle Lizenzen - - Vorschau-Modal zeigt Key-Format - - Standard-Datum-Einstellungen (heute + 1 Jahr) - -2. **Backend-Verarbeitung:** - - Route `/batch` für GET (Formular) und POST (Generierung) - - Generiert die angegebene Anzahl eindeutiger Keys - - Speichert alle in einer Transaktion - - Kunde wird automatisch angelegt (falls nicht vorhanden) - - ON CONFLICT für existierende Kunden - - Audit-Log-Eintrag mit CREATE_BATCH Aktion - -3. **Ergebnis-Seite:** - - Zeigt alle generierten Lizenzen in Tabellenform - - Kundeninformationen und Gültigkeitszeitraum - - Einzelne Keys können kopiert werden (📋 Button) - - Alle Keys auf einmal kopieren - - Druckfunktion für physische Ausgabe - - Link zur Lizenzübersicht mit Kundenfilter - -4. **Export-Funktionalität:** - - Route `/batch/export` für CSV-Download - - Speichert Batch-Daten in Session für Export - - CSV mit UTF-8 BOM für Excel-Kompatibilität - - Enthält Kundeninfo, Generierungsdatum und alle Keys - - Format: Nr;Lizenzschlüssel;Typ - - Dateiname: batch_licenses_KUNDE_TIMESTAMP.csv - -5. **Integration:** - - Batch-Button in Navigation (Dashboard, Einzellizenz-Seite) - - CREATE_BATCH Aktion im Audit-Log (Farbe: #6610f2) - - Session-basierte Export-Daten - - Flash-Messages für Feedback - -**Sicherheit:** -- Limit von 100 Lizenzen pro Batch -- Login-Required für alle Routen -- Transaktionale Datenbank-Operationen -- Validierung der Eingaben - -**Beispiel-Workflow:** -1. Admin geht zu `/batch` -2. Gibt Kunde "Firma GmbH", Anzahl "25", Typ "Vollversion" ein -3. System generiert 25 eindeutige Keys -4. Ergebnis-Seite zeigt alle Keys -5. Admin kann CSV exportieren oder Keys kopieren -6. Kunde erhält die Lizenzen - -**Status:** -- ✅ Batch-Formular vollständig implementiert -- ✅ Backend-Generierung mit Transaktionen -- ✅ Export als CSV -- ✅ Copy-to-Clipboard Funktionalität -- ✅ Audit-Log-Integration -- ✅ Navigation aktualisiert - -## 2025-06-06: Implementierung Searchable Dropdown für Kundenauswahl - -**Problem:** -- Bei der Lizenzerstellung wurde immer ein neuer Kunde angelegt -- Keine Möglichkeit, Lizenzen für bestehende Kunden zu erstellen -- Bei vielen Kunden wäre ein normales Dropdown unübersichtlich - -**Lösung:** -1. **Select2 Library** für searchable Dropdown integriert -2. **API-Endpoint `/api/customers`** für die Kundensuche erstellt -3. **Frontend angepasst:** - - Searchable Dropdown mit Live-Suche - - Option "Neuer Kunde" im Dropdown - - Eingabefelder erscheinen nur bei "Neuer Kunde" -4. **Backend-Logik verbessert:** - - Prüfung ob neuer oder bestehender Kunde - - E-Mail-Duplikatsprüfung vor Kundenerstellung - - Separate Audit-Logs für Kunde und Lizenz -5. **Datenbank:** - - UNIQUE Constraint auf E-Mail-Spalte hinzugefügt - -**Änderungen:** -- `app.py`: Neuer API-Endpoint `/api/customers`, angepasste Routes `/create` und `/batch` -- `base.html`: Select2 CSS und JS eingebunden -- `index.html`: Kundenauswahl mit Select2 implementiert -- `batch_form.html`: Kundenauswahl mit Select2 implementiert -- `init.sql`: UNIQUE Constraint für E-Mail - -**Status:** -- ✅ API-Endpoint funktioniert mit Pagination -- ✅ Select2 Dropdown mit Suchfunktion -- ✅ Neue/bestehende Kunden können ausgewählt werden -- ✅ E-Mail-Duplikate werden verhindert -- ✅ Sowohl Einzellizenz als auch Batch unterstützt - -## 2025-06-06: Automatische Ablaufdatum-Berechnung - -**Problem:** -- Manuelles Eingeben von Start- und Enddatum war umständlich -- Fehleranfällig bei der Datumseingabe -- Nicht intuitiv für Standard-Laufzeiten - -**Lösung:** -1. **Frontend-Änderungen:** - - Startdatum + Laufzeit (Zahl) + Einheit (Tage/Monate/Jahre) - - Ablaufdatum wird automatisch berechnet und angezeigt (read-only) - - Standard: 1 Jahr Laufzeit voreingestellt -2. **Backend-Validierung:** - - Server-seitige Berechnung zur Sicherheit - - Verwendung von `python-dateutil` für korrekte Monats-/Jahresberechnungen -3. **Benutzerfreundlichkeit:** - - Sofortige Neuberechnung bei Änderungen - - Visuelle Hervorhebung des berechneten Datums - -**Änderungen:** -- `index.html`: Laufzeit-Eingabe statt Ablaufdatum -- `batch_form.html`: Laufzeit-Eingabe statt Ablaufdatum -- `app.py`: Datum-Berechnung in `/create` und `/batch` Routes -- `requirements.txt`: `python-dateutil` hinzugefügt - -**Status:** -- ✅ Automatische Berechnung funktioniert -- ✅ Frontend zeigt berechnetes Datum sofort an -- ✅ Backend validiert die Berechnung -- ✅ Standardwert (1 Jahr) voreingestellt - -## 2025-06-06: Bugfix - created_at für licenses Tabelle - -**Problem:** -- Batch-Generierung schlug fehl mit "Fehler bei der Batch-Generierung!" -- INSERT Statement versuchte `created_at` zu setzen, aber Spalte existierte nicht -- Inkonsistenz: Einzellizenzen hatten kein created_at, Batch-Lizenzen versuchten es zu setzen - -**Lösung:** -1. **Datenbank-Schema erweitert:** - - `created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` zur licenses Tabelle hinzugefügt - - Migration für bestehende Datenbanken implementiert - - Konsistent mit customers Tabelle -2. **Code bereinigt:** - - Explizites `created_at` aus Batch-INSERT entfernt - - Datenbank setzt nun automatisch den Zeitstempel bei ALLEN Lizenzen - -**Änderungen:** -- `init.sql`: created_at Spalte zur licenses Tabelle mit DEFAULT-Wert -- `init.sql`: Migration für bestehende Datenbanken -- `app.py`: Entfernt explizites created_at aus batch_licenses() - -**Status:** -- ✅ Alle Lizenzen haben nun automatisch einen Erstellungszeitstempel -- ✅ Batch-Generierung funktioniert wieder -- ✅ Konsistente Zeitstempel für Audit-Zwecke - -## 2025-06-06: Status "Deaktiviert" für manuell abgeschaltete Lizenzen - -**Problem:** -- Dashboard zeigte nur "aktiv" und "abgelaufen" als Status -- Manuell deaktivierte Lizenzen (is_active = FALSE) wurden nicht korrekt angezeigt -- Filter für "inactive" existierte, aber Status wurde nicht richtig berechnet - -**Lösung:** -1. **Status-Berechnung erweitert:** - - CASE-Statement prüft zuerst `is_active = FALSE` - - Status "deaktiviert" wird vor anderen Status geprüft - - Reihenfolge: deaktiviert → abgelaufen → läuft bald ab → aktiv -2. **Dashboard-Statistik erweitert:** - - Neue Zählung für deaktivierte Lizenzen - - Variable `inactive_licenses` im stats Dictionary - -**Änderungen:** -- `app.py`: Dashboard - Status-Berechnung für letzte 5 Lizenzen -- `app.py`: Lizenzübersicht - Status-Berechnung in der Hauptabfrage -- `app.py`: Export - Status-Berechnung für CSV/Excel Export -- `app.py`: Dashboard - Neue Statistik für deaktivierte Lizenzen - -**Status:** -- ✅ "Deaktiviert" wird korrekt als Status angezeigt -- ✅ Dashboard zeigt Anzahl deaktivierter Lizenzen -- ✅ Export enthält korrekten Status -- ✅ Konsistente Status-Anzeige überall - -## 2025-06-08: SSL-Sicherheit verbessert - Chrome Warnung behoben - -**Problem:** -- Chrome zeigte Warnung "Die Verbindung zu dieser Website ist nicht sicher" -- Nginx erlaubte schwache Cipher Suites (WEAK) ohne Perfect Forward Secrecy -- Veraltete SSL-Konfiguration mit `ssl_ciphers HIGH:!aNULL:!MD5;` - -**Lösung:** -1. **Moderne Cipher Suite Konfiguration:** - - Nur sichere ECDHE und DHE Cipher Suites - - Entfernung aller RSA-only Cipher Suites - - Perfect Forward Secrecy für alle Verbindungen -2. **SSL-Optimierungen:** - - Session Cache aktiviert (1 Tag Timeout) - - OCSP Stapling für bessere Performance - - DH Parameters (2048 bit) für zusätzliche Sicherheit -3. **Resolver-Konfiguration:** - - Google DNS Server für OCSP Stapling - -**Änderungen:** -- `v2_nginx/nginx.conf`: Komplett überarbeitete SSL-Konfiguration -- `v2_nginx/ssl/dhparam.pem`: Neue 2048-bit DH Parameters generiert -- `v2_nginx/Dockerfile`: COPY Befehl für dhparam.pem hinzugefügt - -**Status:** -- ✅ Nur noch sichere Cipher Suites aktiv -- ✅ Perfect Forward Secrecy gewährleistet -- ✅ OCSP Stapling aktiviert -- ✅ Chrome Sicherheitswarnung behoben - -**Hinweis:** Nach dem Rebuild des nginx Containers wird die Verbindung als sicher angezeigt. - -## 2025-06-08: CAPTCHA-Login-Bug behoben - -**Problem:** -- Nach 2 fehlgeschlagenen Login-Versuchen wurde CAPTCHA angezeigt -- Da keine CAPTCHA-Keys konfiguriert waren (für PoC), konnte man sich nicht mehr einloggen -- Selbst mit korrektem Passwort war Login blockiert -- Fehlermeldung "CAPTCHA ERFORDERLICH!" erschien immer - -**Lösung:** -1. **CAPTCHA-Prüfung nur wenn Keys vorhanden:** - - `recaptcha_site_key` wird vor CAPTCHA-Prüfung geprüft - - Wenn keine Keys konfiguriert → kein CAPTCHA-Check - - CAPTCHA wird nur angezeigt wenn Keys existieren -2. **Template-Anpassungen:** - - login.html zeigt CAPTCHA nur wenn `recaptcha_site_key` vorhanden - - Kein Test-Key mehr als Fallback -3. **Konsistente Logik:** - - show_captcha prüft jetzt auch ob Keys vorhanden sind - - Bei GET und POST Requests gleiche Logik - -**Änderungen:** -- `v2_adminpanel/app.py`: CAPTCHA-Check nur wenn `RECAPTCHA_SITE_KEY` existiert -- `v2_adminpanel/templates/login.html`: CAPTCHA nur anzeigen wenn Keys vorhanden - -**Status:** -- ✅ Login funktioniert wieder nach 2+ Fehlversuchen -- ✅ CAPTCHA wird nur angezeigt wenn Keys konfiguriert sind -- ✅ Für PoC-Phase ohne CAPTCHA nutzbar -- ✅ Produktiv-ready wenn CAPTCHA-Keys eingetragen werden - -### 2025-06-08: Zeitzone auf Europe/Berlin umgestellt - -**Problem:** -- Alle Zeitstempel wurden in UTC gespeichert und angezeigt -- Backup-Dateinamen zeigten UTC-Zeit statt deutsche Zeit -- Verwirrung bei Zeitangaben im Admin Panel und Logs - -**Lösung:** -1. **Docker Container Zeitzone konfiguriert:** - - Alle Dockerfiles mit `TZ=Europe/Berlin` und tzdata Installation - - PostgreSQL mit `PGTZ=Europe/Berlin` für Datenbank-Zeitzone - - Explizite Zeitzone-Dateien in /etc/localtime und /etc/timezone - -2. **Python Code angepasst:** - - Import von `zoneinfo.ZoneInfo` für Zeitzonenunterstützung - - Alle `datetime.now()` Aufrufe mit `ZoneInfo("Europe/Berlin")` - - `.replace(tzinfo=None)` für Kompatibilität mit timezone-unaware Timestamps - -3. **PostgreSQL Konfiguration:** - - `SET timezone = 'Europe/Berlin';` in init.sql - - Umgebungsvariablen TZ und PGTZ in docker-compose.yaml - -4. **docker-compose.yaml erweitert:** - - `TZ: Europe/Berlin` für alle Services - -**Geänderte Dateien:** -- `v2_adminpanel/Dockerfile`: Zeitzone und tzdata hinzugefügt -- `v2_postgres/Dockerfile`: Zeitzone und tzdata hinzugefügt -- `v2_nginx/Dockerfile`: Zeitzone und tzdata hinzugefügt -- `v2_lizenzserver/Dockerfile`: Zeitzone und tzdata hinzugefügt -- `v2_adminpanel/app.py`: 14 datetime.now() Aufrufe mit Zeitzone versehen -- `v2_adminpanel/init.sql`: PostgreSQL Zeitzone gesetzt -- `v2/docker-compose.yaml`: TZ Environment-Variable für alle Services - -**Ergebnis:** -- ✅ Alle neuen Zeitstempel werden in deutscher Zeit (Europe/Berlin) gespeichert -- ✅ Backup-Dateinamen zeigen korrekte deutsche Zeit -- ✅ Admin Panel zeigt alle Zeiten in deutscher Zeitzone -- ✅ Automatische Anpassung bei Sommer-/Winterzeit -- ✅ Konsistente Zeitangaben über alle Komponenten - -**Hinweis:** Nach diesen Änderungen müssen die Docker Container neu gebaut werden: -```bash -docker-compose down -docker-compose build -docker-compose up -d -``` - -### 2025-06-08: Zeitzone-Fix - PostgreSQL Timestamps - -**Problem nach erster Implementierung:** -- Trotz Zeitzoneneinstellung wurden Zeiten immer noch in UTC angezeigt -- Grund: PostgreSQL Tabellen verwendeten `TIMESTAMP WITHOUT TIME ZONE` - -**Zusätzliche Lösung:** -1. **Datenbankschema angepasst:** - - Alle `TIMESTAMP` Spalten auf `TIMESTAMP WITH TIME ZONE` geändert - - Betrifft: created_at, timestamp, started_at, ended_at, last_heartbeat, etc. - - Migration für bestehende Datenbanken berücksichtigt - -2. **SQL-Abfragen vereinfacht:** - - `AT TIME ZONE 'Europe/Berlin'` nicht mehr nötig - - PostgreSQL handhabt Zeitzonenkonvertierung automatisch - -**Geänderte Datei:** -- `v2_adminpanel/init.sql`: Alle TIMESTAMP Felder mit WITH TIME ZONE - -**Wichtig:** Bei bestehenden Installationen muss die Datenbank neu initialisiert oder manuell migriert werden: -```sql -ALTER TABLE customers ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE licenses ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE sessions ALTER COLUMN started_at TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE sessions ALTER COLUMN last_heartbeat TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE sessions ALTER COLUMN ended_at TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE audit_log ALTER COLUMN timestamp TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE backup_history ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE login_attempts ALTER COLUMN first_attempt TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE login_attempts ALTER COLUMN last_attempt TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE login_attempts ALTER COLUMN blocked_until TYPE TIMESTAMP WITH TIME ZONE; -``` - -### 2025-06-08: UI/UX Überarbeitung - Phase 1 (Navigation) - -**Problem:** -- Inkonsistente Navigation zwischen verschiedenen Seiten -- Zu viele Navigationspunkte im Dashboard -- Verwirrende Benutzerführung - -**Lösung:** -1. **Dashboard vereinfacht:** - - Nur noch 3 Buttons: Neue Lizenz, Batch-Lizenzen, Log - - Statistik-Karten wurden klickbar gemacht (verlinken zu jeweiligen Seiten) - - "Audit" wurde zu "Log" umbenannt - -2. **Navigation konsistent gemacht:** - - Navbar-Brand "AccountForger - Admin Panel" ist jetzt klickbar und führt zum Dashboard - - Keine Log-Links mehr in Unterseiten - - Konsistente "Dashboard" Buttons in allen Unterseiten - -**Geänderte Dateien:** -- `v2_adminpanel/templates/base.html`: Navbar-Brand klickbar gemacht -- `v2_adminpanel/templates/dashboard.html`: Navigation reduziert, Karten klickbar -- `v2_adminpanel/templates/*.html`: Konsistente Dashboard-Links - -### 2025-06-08: UI/UX Überarbeitung - Phase 2 (Visuelle Verbesserungen) - -**Implementierte Verbesserungen:** -1. **Größere Icons in Statistik-Karten:** - - Icon-Größe auf 3rem erhöht - - Bessere visuelle Hierarchie - -2. **Donut-Chart für Lizenzen:** - - Chart.js Integration für Lizenzstatistik - - Zeigt Verhältnis Aktiv/Abgelaufen - - UPDATE: Später wieder entfernt auf Benutzerwunsch - -3. **Pulse-Effekt für aktive Sessions:** - - CSS-Animation für aktive Sessions - - Visueller Indikator für Live-Aktivität - -4. **Progress-Bar für Backup-Status:** - - Zeigt visuell den Erfolg des letzten Backups - - Inkl. Dateigröße und Dauer - -5. **Konsistente Farbcodierung:** - - CSS-Variablen für Statusfarben - - Globale Klassen für konsistente Darstellung - -**Geänderte Dateien:** -- `v2_adminpanel/templates/base.html`: Globale CSS-Variablen und Statusklassen -- `v2_adminpanel/templates/dashboard.html`: Visuelle Verbesserungen implementiert - -### 2025-06-08: UI/UX Überarbeitung - Phase 3 (Tabellen-Optimierungen) - -**Problem:** -- Tabellen waren schwer zu navigieren bei vielen Einträgen -- Keine Möglichkeit für Bulk-Operationen -- Umständliches Kopieren von Lizenzschlüsseln - -**Lösung:** -1. **Sticky Headers:** - - Tabellenköpfe bleiben beim Scrollen sichtbar - - CSS-Klasse `.table-sticky` mit `position: sticky` - -2. **Inline-Actions:** - - Copy-Button direkt neben Lizenzschlüsseln - - Toggle-Switches für Aktiv/Inaktiv-Status - - Visuelles Feedback bei Aktionen - -3. **Bulk-Actions:** - - Checkboxen für Mehrfachauswahl - - "Select All" Funktionalität - - Bulk-Actions Bar mit Aktivieren/Deaktivieren/Löschen - - JavaScript für dynamische Anzeige - -4. **API-Endpoints hinzugefügt:** - - `/api/license//toggle` - Toggle einzelner Lizenzstatus - - `/api/licenses/bulk-activate` - Mehrere Lizenzen aktivieren - - `/api/licenses/bulk-deactivate` - Mehrere Lizenzen deaktivieren - - `/api/licenses/bulk-delete` - Mehrere Lizenzen löschen - -5. **Beispieldaten eingefügt:** - - 15 Testkunden - - 18 Lizenzen (verschiedene Status) - - Sessions, Audit-Logs, Login-Attempts - - Backup-Historie - -**Geänderte Dateien:** -- `v2_adminpanel/templates/base.html`: CSS für Sticky-Tables und Bulk-Actions -- `v2_adminpanel/templates/licenses.html`: Komplette Tabellen-Überarbeitung -- `v2_adminpanel/app.py`: 4 neue API-Endpoints für Toggle und Bulk-Operationen -- `v2_adminpanel/sample_data.sql`: Umfangreiche Testdaten erstellt - -**Bugfix:** -- API-Endpoints versuchten `updated_at` zu setzen, obwohl die Spalte nicht existiert -- Entfernt aus allen 3 betroffenen Endpoints - -**Status:** -- ✅ Sticky Headers funktionieren -- ✅ Copy-Buttons mit Clipboard-API -- ✅ Toggle-Switches ändern Lizenzstatus -- ✅ Bulk-Operationen vollständig implementiert -- ✅ Testdaten erfolgreich eingefügt - -### 2025-06-08: UI/UX Überarbeitung - Phase 4 (Sortierbare Tabellen) - -**Problem:** -- Keine Möglichkeit, Tabellen nach verschiedenen Spalten zu sortieren -- Besonders bei großen Datenmengen schwer zu navigieren - -**Lösung - Hybrid-Ansatz:** -1. **Client-seitige Sortierung für kleine Tabellen:** - - Dashboard (3 kleine Übersichtstabellen) - - Blocked IPs (typischerweise wenige Einträge) - - Backups (begrenzte Anzahl) - - JavaScript-basierte Sortierung ohne Reload - -2. **Server-seitige Sortierung für große Tabellen:** - - Licenses (potenziell tausende Einträge) - - Customers (viele Kunden möglich) - - Audit Log (wächst kontinuierlich) - - Sessions (viele aktive/beendete Sessions) - - URL-Parameter für Sortierung mit SQL ORDER BY - -**Implementierung:** -1. **Client-seitige Sortierung:** - - Generische JavaScript-Funktion in base.html - - CSS-Klasse `.sortable-table` für betroffene Tabellen - - Sortier-Indikatoren (↑↓↕) bei Hover/Active - - Unterstützung für Text, Zahlen und deutsche Datumsformate - -2. **Server-seitige Sortierung:** - - Query-Parameter `sort` und `order` in Routes - - Whitelist für erlaubte Sortierfelder (SQL-Injection-Schutz) - - Makro-Funktionen für sortierbare Header - - Sortier-Parameter in Pagination-Links erhalten - -**Geänderte Dateien:** -- `v2_adminpanel/templates/base.html`: CSS und JavaScript für Sortierung -- `v2_adminpanel/templates/dashboard.html`: Client-seitige Sortierung -- `v2_adminpanel/templates/blocked_ips.html`: Client-seitige Sortierung -- `v2_adminpanel/templates/backups.html`: Client-seitige Sortierung -- `v2_adminpanel/templates/licenses.html`: Server-seitige Sortierung -- `v2_adminpanel/templates/customers.html`: Server-seitige Sortierung -- `v2_adminpanel/templates/audit_log.html`: Server-seitige Sortierung -- `v2_adminpanel/templates/sessions.html`: Server-seitige Sortierung (2 Tabellen) -- `v2_adminpanel/app.py`: 4 Routes erweitert für Sortierung - -**Besonderheiten:** -- Sessions-Seite hat zwei unabhängige Tabellen mit eigenen Sortierparametern -- Intelligente Datentyp-Erkennung (numeric, date) für korrekte Sortierung -- Visuelle Sortier-Indikatoren zeigen aktuelle Sortierung -- Alle anderen Filter und Suchparameter bleiben bei Sortierung erhalten - -**Status:** -- ✅ Client-seitige Sortierung für kleine Tabellen -- ✅ Server-seitige Sortierung für große Tabellen -- ✅ Sortier-Indikatoren und visuelle Rückmeldung -- ✅ SQL-Injection-Schutz durch Whitelisting -- ✅ Vollständige Integration mit bestehenden Features - -### 2025-06-08: Bugfix - Sortierlogik korrigiert - -**Problem:** -- Sortierung funktionierte nicht korrekt -- Beim Klick auf Spaltenköpfe wurde immer absteigend sortiert -- Toggle zwischen ASC/DESC funktionierte nicht - -**Ursachen:** -1. **Falsche Bedingungslogik**: Die ursprüngliche Implementierung verwendete eine fehlerhafte Ternär-Bedingung -2. **Berechnete Felder**: Das 'status' Feld in der Lizenztabelle konnte nicht direkt sortiert werden - -**Lösung:** -1. **Sortierlogik korrigiert:** - - Bei neuer Spalte: Immer aufsteigend (ASC) beginnen - - Bei gleicher Spalte: Toggle zwischen ASC und DESC - - Implementiert durch bedingte Links in den Makros - -2. **Spezialbehandlung für berechnete Felder:** - - Status-Feld verwendet CASE-Statement in ORDER BY - - Wiederholt die gleiche Logik wie im SELECT - -**Geänderte Dateien:** -- `v2_adminpanel/templates/licenses.html`: Sortierlogik korrigiert -- `v2_adminpanel/templates/customers.html`: Sortierlogik korrigiert -- `v2_adminpanel/templates/audit_log.html`: Sortierlogik korrigiert -- `v2_adminpanel/templates/sessions.html`: Sortierlogik für beide Tabellen korrigiert -- `v2_adminpanel/app.py`: Spezialbehandlung für Status-Feld in licenses Route - -**Verhalten nach Fix:** -- ✅ Erster Klick auf Spalte: Aufsteigend sortieren -- ✅ Zweiter Klick: Absteigend sortieren -- ✅ Weitere Klicks: Toggle zwischen ASC/DESC -- ✅ Sortierung funktioniert korrekt mit Pagination und Filtern - -### 2025-06-09: Port 8443 geschlossen - API nur noch über Nginx - -**Problem:** -- Doppelte Verfügbarkeit des License Servers (Port 8443 + Nginx) machte keinen Sinn -- Inkonsistente Sicherheitskonfiguration (Nginx hatte Security Headers, Port 8443 nicht) -- Doppelte SSL-Konfiguration nötig -- Verwirrung welcher Zugangsweg genutzt werden soll - -**Lösung:** -- Port-Mapping für License Server in docker-compose.yaml entfernt -- API nur noch über Nginx erreichbar: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com -- Interne Kommunikation zwischen Nginx und License Server bleibt bestehen - -**Vorteile:** -- ✅ Einheitliche Sicherheitskonfiguration (Security Headers, HSTS) -- ✅ Zentrale SSL-Verwaltung nur in Nginx -- ✅ Möglichkeit für Rate Limiting und zentrales Logging -- ✅ Keine zusätzlichen offenen Ports (nur 80/443) -- ✅ Professionellere API-URL ohne Port-Angabe - -**Geänderte Dateien:** -- `v2/docker-compose.yaml`: Port-Mapping "8443:8443" entfernt - -**Hinweis für Client-Software:** -- API-Endpunkte sind weiterhin unter https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com erreichbar -- Keine Änderung der API-URLs nötig, nur Port 8443 ist nicht mehr direkt zugänglich - -**Status:** -- ✅ Port 8443 geschlossen -- ✅ API nur noch über Nginx Reverse Proxy erreichbar -- ✅ Sicherheit erhöht durch zentrale Verwaltung - -### 2025-06-09: Live-Filtering implementiert - -**Problem:** -- Benutzer mussten immer auf "Filter anwenden" klicken -- Umständliche Bedienung, besonders bei mehreren Filterkriterien -- Nicht zeitgemäße User Experience - -**Lösung:** -- JavaScript Event-Listener für automatisches Filtern -- Text-Eingaben: 300ms Debouncing (verzögerte Suche nach Tipp-Pause) -- Dropdowns: Sofortiges Filtern bei Änderung -- "Filter anwenden" Button entfernt, nur "Zurücksetzen" bleibt - -**Implementierte Live-Filter:** -1. **Lizenzübersicht** (licenses.html): - - Suchfeld mit Debouncing - - Typ-Dropdown (Vollversion/Testversion) - - Status-Dropdown (Aktiv/Ablaufend/Abgelaufen/Deaktiviert) - -2. **Kundenübersicht** (customers.html): - - Suchfeld mit Debouncing - - "Suchen" Button entfernt - -3. **Audit-Log** (audit_log.html): - - Benutzer-Textfeld mit Debouncing - - Aktion-Dropdown - - Entität-Dropdown - -**Technische Details:** -- `addEventListener('input')` für Textfelder -- `addEventListener('change')` für Select-Elemente -- `setTimeout()` mit 300ms für Debouncing -- Automatisches `form.submit()` bei Änderungen - -**Vorteile:** -- ✅ Schnellere und intuitivere Bedienung -- ✅ Weniger Klicks erforderlich -- ✅ Moderne User Experience -- ✅ Besonders hilfreich bei komplexen Filterkriterien - -**Status:** -- ✅ Live-Filtering auf allen Hauptseiten implementiert -- ✅ Debouncing verhindert zu viele Server-Requests -- ✅ Zurücksetzen-Button bleibt für schnelles Löschen aller Filter - -### 2025-06-09: Resource Pool System implementiert (Phase 1 & 2) - -**Ziel:** -Ein Pool-System für Domains, IPv4-Adressen und Telefonnummern, wobei bei jeder Lizenzerstellung 1-10 Ressourcen pro Typ zugewiesen werden. Ressourcen haben 3 Status: available, allocated, quarantine. - -**Phase 1 - Datenbank-Schema (✅ Abgeschlossen):** -1. **Neue Tabellen erstellt:** - - `resource_pools` - Haupttabelle für alle Ressourcen - - `resource_history` - Vollständige Historie aller Aktionen - - `resource_metrics` - Performance-Tracking und ROI-Berechnung - - `license_resources` - Zuordnung zwischen Lizenzen und Ressourcen - -2. **Erweiterte licenses Tabelle:** - - `domain_count`, `ipv4_count`, `phone_count` Spalten hinzugefügt - - Constraints: 0-10 pro Resource-Typ - -3. **Indizes für Performance:** - - Status, Type, Allocated License, Quarantine Date - -**Phase 2 - Backend-Implementierung (✅ Abgeschlossen):** -1. **Resource Management Routes:** - - `/resources` - Hauptübersicht mit Statistiken - - `/resources/add` - Bulk-Import von Ressourcen - - `/resources/quarantine/` - Ressourcen sperren - - `/resources/release` - Quarantäne aufheben - - `/resources/history/` - Komplette Historie - - `/resources/metrics` - Performance Dashboard - - `/resources/report` - Report-Generator - -2. **API-Endpunkte:** - - `/api/resources/allocate` - Ressourcen-Zuweisung - - `/api/resources/check-availability` - Verfügbarkeit prüfen - -3. **Integration in Lizenzerstellung:** - - `create_license()` erweitert um Resource-Allocation - - `batch_licenses()` mit Ressourcen-Prüfung für gesamten Batch - - Transaktionale Sicherheit bei Zuweisung - -4. **Dashboard-Integration:** - - Resource-Statistiken in Dashboard eingebaut - - Warning-Level basierend auf Verfügbarkeit - -5. **Navigation erweitert:** - - Resources-Link in Navbar hinzugefügt - -**Was noch zu tun ist:** - -### Phase 3 - UI-Komponenten (🔄 Ausstehend): -1. **Templates erstellen:** - - `resources.html` - Hauptübersicht mit Drag&Drop - - `add_resources.html` - Formular für Bulk-Import - - `resource_history.html` - Historie-Anzeige - - `resource_metrics.html` - Performance Dashboard - -2. **Formulare erweitern:** - - `index.html` - Resource-Dropdowns hinzufügen - - `batch_form.html` - Resource-Dropdowns hinzufügen - -3. **Dashboard-Widget:** - - Resource Pool Statistik mit Ampelsystem - - Warnung bei niedrigem Bestand - -### Phase 4 - Erweiterte Features (🔄 Ausstehend): -1. **Quarantäne-Workflow:** - - Gründe: abuse, defect, maintenance, blacklisted, expired - - Automatische Tests vor Freigabe - - Genehmigungsprozess - -2. **Performance-Metrics:** - - Täglicher Cronjob für Metriken - - ROI-Berechnung - - Issue-Tracking - -3. **Report-Generator:** - - Auslastungsreport - - Performance-Report - - Compliance-Report - -### Phase 5 - Backup erweitern (🔄 Ausstehend): -- Neue Tabellen in Backup einbeziehen: - - resource_pools - - resource_history - - resource_metrics - - license_resources - -### Phase 6 - Testing & Migration (🔄 Ausstehend): -1. **Test-Daten generieren:** - - 500 Test-Domains - - 200 Test-IPs - - 100 Test-Telefonnummern - -2. **Migrations-Script:** - - Bestehende Lizenzen auf default resource_count setzen - -### Phase 7 - Dokumentation (🔄 Ausstehend): -- API-Dokumentation für License Server -- Admin-Handbuch für Resource Management - -**Technische Details:** -- 3-Status-System: available/allocated/quarantine -- Transaktionale Ressourcen-Zuweisung mit FOR UPDATE Lock -- Vollständige Historie mit IP-Tracking -- Drag&Drop UI für Resource-Management geplant -- Automatische Warnung bei < 50 verfügbaren Ressourcen - -**Status:** -- ✅ Datenbank-Schema komplett -- ✅ Backend-Routen implementiert -- ✅ Integration in Lizenzerstellung -- ❌ UI-Templates fehlen noch -- ❌ Erweiterte Features ausstehend -- ❌ Testing und Migration offen - -### 2025-06-09: Resource Pool System UI-Implementierung (Phase 3 & 4) - -**Phase 3 - UI-Komponenten (✅ Abgeschlossen):** - -1. **Neue Templates erstellt:** - - `resources.html` - Hauptübersicht mit Statistiken, Filter, Live-Suche, Pagination - - `add_resources.html` - Bulk-Import Formular mit Validierung - - `resource_history.html` - Timeline-Ansicht der Historie mit Details - - `resource_metrics.html` - Performance Dashboard mit Charts - - `resource_report.html` - Report-Generator UI - -2. **Erweiterte Formulare:** - - `index.html` - Resource-Count Dropdowns (0-10) mit Live-Verfügbarkeitsprüfung - - `batch_form.html` - Resource-Count mit Batch-Berechnung (zeigt Gesamtbedarf) - -3. **Dashboard-Widget:** - - Resource Pool Statistik mit Ampelsystem implementiert - - Zeigt verfügbare/zugeteilte/quarantäne Ressourcen - - Warnung bei niedrigem Bestand (<50) - - Fortschrittsbalken für visuelle Darstellung - -4. **Backend-Anpassungen:** - - `resource_history` Route korrigiert für Object-Style Template-Zugriff - - `resources_metrics` Route vollständig implementiert mit Charts-Daten - - `resources_report` Route erweitert für Template-Anzeige und Downloads - - Dashboard erweitert um Resource-Statistiken - -**Phase 4 - Erweiterte Features (✅ Teilweise):** -1. **Report-Generator:** - - Template für Report-Auswahl erstellt - - 4 Report-Typen: Usage, Performance, Compliance, Inventory - - Export als Excel, CSV oder PDF-Vorschau - - Zeitraum-Auswahl mit Validierung - -**Technische Details der Implementierung:** -- Live-Filtering ohne Reload durch JavaScript -- AJAX-basierte Verfügbarkeitsprüfung -- Bootstrap 5 für konsistentes Design -- Chart.js für Metriken-Visualisierung -- Responsives Design für alle Templates -- Copy-to-Clipboard für Resource-Werte -- Modal-Dialoge für Quarantäne-Aktionen - -**Was noch fehlt:** - -### Phase 5 - Backup erweitern (🔄 Ausstehend): -- Resource-Tabellen in pg_dump einbeziehen: - - resource_pools - - resource_history - - resource_metrics - - license_resources - -### Phase 6 - Testing & Migration (🔄 Ausstehend): -1. **Test-Daten generieren:** - - Script für 500 Test-Domains - - 200 Test-IPv4-Adressen - - 100 Test-Telefonnummern - - Realistische Verteilung über Status - -2. **Migrations-Script:** - - Bestehende Lizenzen auf Default resource_count setzen - - UPDATE licenses SET domain_count=1, ipv4_count=1, phone_count=1 WHERE ... - -### Phase 7 - Dokumentation (🔄 Ausstehend): -- API-Dokumentation für Resource-Endpunkte -- Admin-Handbuch für Resource Management -- Troubleshooting-Guide - -**Offene Punkte für Produktion:** -1. Drag&Drop für Resource-Verwaltung (Nice-to-have) -2. Automatische Quarantäne-Aufhebung nach Zeitablauf -3. E-Mail-Benachrichtigungen bei niedrigem Bestand -4. API für externe Resource-Prüfung -5. Bulk-Delete für Ressourcen -6. Resource-Import aus CSV/Excel - -### 2025-06-09: Resource Pool System finalisiert - -**Problem:** -- Resource Pool System war nur teilweise implementiert -- UI-Templates waren vorhanden, aber nicht dokumentiert -- Test-Daten und Migration fehlten -- Backup-Integration unklar - -**Analyse und Lösung:** -1. **Status-Überprüfung durchgeführt:** - - Alle 5 UI-Templates existierten bereits (resources.html, add_resources.html, etc.) - - Resource-Dropdowns waren bereits in index.html und batch_form.html integriert - - Dashboard-Widget war bereits implementiert - - Backup-System inkludiert bereits alle Tabellen (pg_dump ohne -t Parameter) - -2. **Fehlende Komponenten erstellt:** - - Test-Daten Script: `test_data_resources.sql` - - 500 Test-Domains (400 verfügbar, 50 zugeteilt, 50 in Quarantäne) - - 200 Test-IPv4-Adressen (150 verfügbar, 30 zugeteilt, 20 in Quarantäne) - - 100 Test-Telefonnummern (70 verfügbar, 20 zugeteilt, 10 in Quarantäne) - - Resource History und Metrics für realistische Daten - - - Migration Script: `migrate_existing_licenses.sql` - - Setzt Default Resource Counts (Vollversion: 2, Testversion: 1, Inaktiv: 0) - - Weist automatisch verfügbare Ressourcen zu - - Erstellt Audit-Log Einträge - - Gibt detaillierten Migrationsbericht aus - -**Neue Dateien:** -- `v2_adminpanel/test_data_resources.sql` - Testdaten für Resource Pool -- `v2_adminpanel/migrate_existing_licenses.sql` - Migration für bestehende Lizenzen - -**Status:** -- ✅ Resource Pool System vollständig implementiert und dokumentiert -- ✅ Alle UI-Komponenten vorhanden und funktionsfähig -- ✅ Integration in Lizenz-Formulare abgeschlossen -- ✅ Dashboard-Widget zeigt Resource-Statistiken -- ✅ Backup-System inkludiert Resource-Tabellen -- ✅ Test-Daten und Migration bereitgestellt - -**Nächste Schritte:** -1. Test-Daten einspielen: `psql -U adminuser -d meinedatenbank -f test_data_resources.sql` -2. Migration ausführen: `psql -U adminuser -d meinedatenbank -f migrate_existing_licenses.sql` -3. License Server API implementieren (Hauptaufgabe) - -### 2025-06-09: Bugfix - Resource Pool Tabellen fehlten - -**Problem:** -- Admin Panel zeigte "Internal Server Error" -- Dashboard Route versuchte auf `resource_pools` Tabelle zuzugreifen -- Tabelle existierte nicht in der Datenbank - -**Ursache:** -- Bei bereits existierender Datenbank wird init.sql nicht erneut ausgeführt -- Resource Pool Tabellen wurden erst später zum init.sql hinzugefügt -- Docker Container verwendeten noch die alte Datenbankstruktur - -**Lösung:** -1. Separates Script `create_resource_tables.sql` erstellt -2. Script manuell in der Datenbank ausgeführt -3. Alle 4 Resource-Tabellen erfolgreich erstellt: - - resource_pools - - resource_history - - resource_metrics - - license_resources - -**Status:** -- ✅ Admin Panel funktioniert wieder -- ✅ Dashboard zeigt Resource Pool Statistiken -- ✅ Alle Resource-Funktionen verfügbar - -**Empfehlung für Neuinstallationen:** -- Bei frischer Installation funktioniert alles automatisch -- Bei bestehenden Installationen: `create_resource_tables.sql` ausführen - -### 2025-06-09: Navigation vereinfacht - -**Änderung:** -- Navigationspunkte aus der schwarzen Navbar entfernt -- Links zu Lizenzen, Kunden, Ressourcen, Sessions, Backups und Log entfernt - -**Grund:** -- Cleaner Look mit nur Logo, Timer und Logout -- Alle Funktionen sind weiterhin über das Dashboard erreichbar -- Bessere Übersichtlichkeit und weniger Ablenkung - -**Geänderte Datei:** -- `v2_adminpanel/templates/base.html` - Navbar-Links auskommentiert - -**Status:** -- ✅ Navbar zeigt nur noch Logo, Session-Timer und Logout -- ✅ Navigation erfolgt über Dashboard und Buttons auf den jeweiligen Seiten -- ✅ Alle Funktionen bleiben erreichbar - -### 2025-06-09: Bugfix - Resource Report Einrückungsfehler - -**Problem:** -- Resource Report Route zeigte "Internal Server Error" -- UnboundLocalError: `report_type` wurde verwendet bevor es definiert war - -**Ursache:** -- Fehlerhafte Einrückung in der `resources_report()` Funktion -- `elif` und `else` Blöcke waren falsch eingerückt -- Variablen wurden außerhalb ihres Gültigkeitsbereichs verwendet - -**Lösung:** -- Korrekte Einrückung für alle Conditional-Blöcke wiederhergestellt -- Alle Report-Typen (usage, performance, compliance, inventory) richtig strukturiert -- Excel und CSV Export-Code korrekt eingerückt - -**Geänderte Datei:** -- `v2_adminpanel/app.py` - resources_report() Funktion korrigiert - -**Status:** -- ✅ Resource Report funktioniert wieder -- ✅ Alle 4 Report-Typen verfügbar -- ✅ Export als Excel und CSV möglich - ---- - -## Zusammenfassung der heutigen Arbeiten (2025-06-09) - -### 1. Resource Pool System Finalisierung -- **Ausgangslage**: Resource Pool war nur teilweise dokumentiert -- **Überraschung**: UI-Templates waren bereits vorhanden (nicht dokumentiert) -- **Ergänzt**: - - Test-Daten Script (`test_data_resources.sql`) - - Migration Script (`migrate_existing_licenses.sql`) -- **Status**: ✅ Vollständig implementiert - -### 2. Database Migration Bug -- **Problem**: Admin Panel zeigte "Internal Server Error" -- **Ursache**: Resource Pool Tabellen fehlten in bestehender DB -- **Lösung**: Separates Script `create_resource_tables.sql` erstellt -- **Status**: ✅ Behoben - -### 3. UI Cleanup -- **Änderung**: Navigation aus Navbar entfernt -- **Effekt**: Cleaner Look, Navigation nur über Dashboard -- **Status**: ✅ Implementiert - -### 4. Resource Report Bug -- **Problem**: Einrückungsfehler in `resources_report()` Funktion -- **Lösung**: Korrekte Einrückung wiederhergestellt -- **Status**: ✅ Behoben - -### Neue Dateien erstellt heute: -1. `v2_adminpanel/test_data_resources.sql` - 800 Test-Ressourcen - -### 2025-06-09: Bugfix - Resource Quarantäne Modal - -**Problem:** -- Quarantäne-Button funktionierte nicht -- Modal öffnete sich nicht beim Klick - -**Ursache:** -- Bootstrap 5 vs Bootstrap 4 API-Inkompatibilität -- Modal wurde mit Bootstrap 4 Syntax (`modal.modal('show')`) aufgerufen -- jQuery wurde nach Bootstrap geladen - -**Lösung:** -1. **JavaScript angepasst:** - - Von jQuery Modal-API zu nativer Bootstrap 5 Modal-API gewechselt - - `new bootstrap.Modal(element).show()` statt `$(element).modal('show')` - -2. **HTML-Struktur aktualisiert:** - - Modal-Close-Button: `data-bs-dismiss="modal"` statt `data-dismiss="modal"` - - `btn-close` Klasse statt custom close button - - Form-Klassen: `mb-3` statt `form-group`, `form-select` statt `form-control` für Select - -3. **Script-Reihenfolge korrigiert:** - - jQuery vor Bootstrap laden für korrekte Initialisierung - -**Geänderte Dateien:** -- `v2_adminpanel/templates/resources.html` -- `v2_adminpanel/templates/base.html` - -**Status:** ✅ Behoben - -### 2025-06-09: Resource Pool UI Redesign - -**Ziel:** -- Komplettes Redesign des Resource Pool Managements für bessere Benutzerfreundlichkeit -- Konsistentes Design mit dem Rest der Anwendung - -**Durchgeführte Änderungen:** - -1. **resources.html - Hauptübersicht:** - - Moderne Statistik-Karten mit Hover-Effekten - - Farbcodierte Progress-Bars mit Tooltips - - Verbesserte Tabelle mit Icons und Status-Badges - - Live-Filter mit sofortiger Suche - - Überarbeitete Quarantäne-Modal für Bootstrap 5 - - Responsive Design mit Grid-Layout - -2. **add_resources.html - Ressourcen hinzufügen:** - - 3-Schritt Wizard-ähnliches Interface - - Visueller Ressourcentyp-Selector mit Icons - - Live-Validierung mit Echtzeit-Feedback - - Statistik-Anzeige (Gültig/Duplikate/Ungültig) - - Formatierte Beispiele mit Erklärungen - - Verbesserte Fehlerbehandlung - -3. **resource_history.html - Historie:** - - Zentrierte Resource-Anzeige mit großen Icons - - Info-Grid Layout für Details - - Modernisierte Timeline mit Hover-Effekten - - Farbcodierte Action-Icons - - Verbesserte Darstellung von Details - -4. **resource_metrics.html - Metriken:** - - Dashboard-Style Metrik-Karten mit Icon-Badges - - Modernisierte Charts mit besseren Farben - - Performance-Tabellen mit Progress-Bars - - Trend-Indikatoren für Performance - - Responsives Grid-Layout - -**Design-Verbesserungen:** -- Konsistente Emoji-Icons für bessere visuelle Kommunikation -- Einheitliche Farbgebung (Blau/Lila/Grün für Ressourcentypen) -- Card-basiertes Layout mit Schatten und Hover-Effekten -- Bootstrap 5 kompatible Komponenten -- Verbesserte Typografie und Spacing - -**Technische Details:** -- Bootstrap 5 Modal-API statt jQuery -- CSS Grid für responsive Layouts -- Moderne Chart.js Konfiguration -- Optimierte JavaScript-Validierung - -**Geänderte Dateien:** -- `v2_adminpanel/templates/resources.html` -- `v2_adminpanel/templates/add_resources.html` -- `v2_adminpanel/templates/resource_history.html` -- `v2_adminpanel/templates/resource_metrics.html` - -**Status:** ✅ Abgeschlossen - -### 2025-06-09: Zusammenfassung der heutigen Arbeiten - -**Durchgeführte Aufgaben:** - -1. **Quarantäne-Funktion repariert:** - - Bootstrap 5 Modal-API implementiert - - data-bs-dismiss statt data-dismiss - - jQuery vor Bootstrap laden - -2. **Resource Pool UI komplett überarbeitet:** - - Alle 4 Templates modernisiert (resources, add_resources, resource_history, resource_metrics) - - Konsistentes Design mit Emoji-Icons - - Einheitliche Farbgebung (Blau/Lila/Grün) - - Bootstrap 5 kompatible Komponenten - - Responsive Grid-Layouts - -**Aktuelle Projekt-Status:** -- ✅ Admin Panel voll funktionsfähig -- ✅ Resource Pool Management mit modernem UI -- ✅ PostgreSQL mit allen Tabellen -- ✅ Nginx Reverse Proxy mit SSL -- ❌ Lizenzserver noch nicht implementiert (nur Platzhalter) - -**Nächste Schritte:** -- Lizenzserver implementieren -- API-Endpunkte für Lizenzvalidierung -- Heartbeat-System für Sessions -- Versionsprüfung implementieren -1. `v2_adminpanel/templates/base.html` - Navigation entfernt -2. `v2_adminpanel/app.py` - Resource Report Einrückung korrigiert -3. `JOURNAL.md` - Alle Änderungen dokumentiert - -### Offene Hauptaufgabe: -- **License Server API** - Noch komplett zu implementieren - - `/api/version` - Versionscheck - - `/api/validate` - Lizenzvalidierung - - `/api/heartbeat` - Session-Management - -### 2025-06-09: Resource Pool Internal Error behoben - -**Problem:** -- Internal Server Error beim Zugriff auf `/resources` -- NameError: name 'datetime' is not defined in Template - -**Ursache:** -- Fehlende `datetime` und `timedelta` Objekte im Template-Kontext -- Falsche Array-Indizes in resources.html für activity-Daten - -**Lösung:** -1. **app.py (Zeile 2797-2798):** - - `datetime=datetime` und `timedelta=timedelta` zu render_template hinzugefügt - -2. **resources.html (Zeile 484-490):** - - Array-Indizes korrigiert: - - activity[0] = action - - activity[1] = action_by - - activity[2] = action_at - - activity[3] = resource_type - - activity[4] = resource_value - - activity[5] = details - -**Geänderte Dateien:** -- `v2_adminpanel/app.py` -- `v2_adminpanel/templates/resources.html` - -**Status:** ✅ Behoben - Resource Pool funktioniert wieder einwandfrei - -### 2025-06-09: Passwort-Änderung und 2FA implementiert - -**Ziel:** -- Benutzer können ihr Passwort ändern -- Zwei-Faktor-Authentifizierung (2FA) mit TOTP -- Komplett kostenlose Lösung ohne externe Services - -**Implementierte Features:** - -1. **Datenbank-Erweiterung:** - - Neue `users` Tabelle mit Passwort-Hash und 2FA-Feldern - - Unterstützung für TOTP-Secrets und Backup-Codes - - Migration von Environment-Variablen zu Datenbank - -2. **Passwort-Management:** - - Sichere Passwort-Hashes mit bcrypt - - Passwort-Änderung mit Verifikation des alten Passworts - - Passwort-Stärke-Indikator im Frontend - -3. **2FA-Implementation:** - - TOTP-basierte 2FA (Google Authenticator, Authy kompatibel) - - QR-Code-Generierung für einfaches Setup - - 8 Backup-Codes für Notfallzugriff - - Backup-Codes als Textdatei downloadbar - -4. **Neue Routen:** - - `/profile` - Benutzerprofil mit Passwort und 2FA-Verwaltung - - `/verify-2fa` - 2FA-Verifizierung beim Login - - `/profile/setup-2fa` - 2FA-Einrichtung mit QR-Code - - `/profile/enable-2fa` - 2FA-Aktivierung - - `/profile/disable-2fa` - 2FA-Deaktivierung - - `/profile/change-password` - Passwort ändern - -5. **Sicherheits-Features:** - - Fallback zu Environment-Variablen für Rückwärtskompatibilität - - Session-Management für 2FA-Verifizierung - - Fehlgeschlagene 2FA-Versuche werden protokolliert - - Verwendete Backup-Codes werden entfernt - -**Verwendete Libraries (alle kostenlos):** -- `bcrypt` - Passwort-Hashing -- `pyotp` - TOTP-Generierung und Verifizierung -- `qrcode[pil]` - QR-Code-Generierung - -**Migration:** -- Script `migrate_users.py` erstellt für Migration existierender Benutzer -- Erhält bestehende Credentials aus Environment-Variablen -- Erstellt Datenbank-Einträge mit gehashten Passwörtern - -**Geänderte Dateien:** -- `v2_adminpanel/init.sql` - Users-Tabelle hinzugefügt -- `v2_adminpanel/requirements.txt` - Neue Dependencies -- `v2_adminpanel/app.py` - Auth-Funktionen und neue Routen -- `v2_adminpanel/migrate_users.py` - Migrations-Script (neu) -- `v2_adminpanel/templates/base.html` - Profil-Link hinzugefügt -- `v2_adminpanel/templates/profile.html` - Profil-Seite (neu) -- `v2_adminpanel/templates/verify_2fa.html` - 2FA-Verifizierung (neu) -- `v2_adminpanel/templates/setup_2fa.html` - 2FA-Setup (neu) -- `v2_adminpanel/templates/backup_codes.html` - Backup-Codes Anzeige (neu) - -**Status:** ✅ Vollständig implementiert - -### 2025-06-09: Internal Server Error behoben und UI-Design angepasst - -### 2025-06-09: Journal-Bereinigung und Projekt-Cleanup - -**Durchgeführte Aufgaben:** - -1. **Überflüssige SQL-Dateien gelöscht:** - - `create_resource_tables.sql` - War nur für Migrations nötig - - `migrate_existing_licenses.sql` - Keine alten Installationen vorhanden - - `sample_data.sql` - Testdaten nicht mehr benötigt - - `test_data_resources.sql` - Testdaten nicht mehr benötigt - -2. **Journal aktualisiert:** - - Veraltete Todo-Liste korrigiert (viele Features bereits implementiert) - - Passwörter aus Zugangsdaten entfernt (Sicherheit) - - "Bekannte Probleme" auf aktuellen Stand gebracht - - Neuer Abschnitt "Best Practices für Produktiv-Migration" hinzugefügt - -3. **Status-Klärungen:** - - Alle Daten sind Testdaten (PoC-Phase) - - 2FA ist implementiert und funktionsfähig - - Resource Pool System ist vollständig implementiert - - Port 8443 ist geschlossen (nur über Nginx erreichbar) - -**Noch zu erledigen:** -- Nginx Config anpassen (proxy_pass von https:// auf http://) -- License Server API implementieren (Hauptaufgabe) - -**Problem:** -- Internal Server Error nach Login wegen fehlender `users` Tabelle -- UI-Design der neuen 2FA-Seiten passte nicht zum Rest der Anwendung - -**Lösung:** - -1. **Datenbank-Fix:** - - Users-Tabelle wurde nicht automatisch erstellt - - Manuell mit SQL-Script nachgeholt - - Migration erfolgreich durchgeführt - - Beide Admin-User (rac00n, w@rh@mm3r) migriert - -2. **UI-Design Überarbeitung:** - - Profile-Seite im Dashboard-Stil mit Cards und Hover-Effekten - - 2FA-Setup mit nummerierten Schritten und modernem Card-Design - - Backup-Codes Seite mit Animation und verbessertem Layout - - Konsistente Farbgebung und Icons - - Verbesserte Benutzerführung mit visuellen Hinweisen - -**Design-Features:** -- Card-basiertes Layout mit Schatten-Effekten -- Hover-Animationen für bessere Interaktivität -- Farbcodierte Sicherheitsstatus-Anzeigen -- Passwort-Stärke-Indikator mit visueller Rückmeldung -- Responsive Design für alle Bildschirmgrößen -- Print-optimiertes Layout für Backup-Codes - -**Geänderte Dateien:** -- `v2_adminpanel/create_users_table.sql` - SQL für Users-Tabelle (temporär) - -### 2025-06-09: Journal-Umstrukturierung - -**Durchgeführte Änderungen:** - -1. **Dokumentation aufgeteilt:** - - `JOURNAL.md` - Enthält nur noch chronologische Änderungen (wie ein Tagebuch) - - `THE_ROAD_SO_FAR.md` - Neues Dokument mit aktuellem Status und Roadmap - -2. **THE_ROAD_SO_FAR.md erstellt mit:** - - Aktueller Status (was läuft bereits) - - Nächste Schritte (Priorität Hoch) - - Offene Aufgaben (Priorität Mittel) - - Nice-to-have Features - - Bekannte Probleme - - Deployment-Hinweise - -3. **JOURNAL.md bereinigt:** - - Todo-Listen entfernt (jetzt in THE_ROAD_SO_FAR.md) - - Nur noch chronologische Einträge - - Fokus auf "Was wurde gemacht" statt "Was muss gemacht werden" - -**Vorteile der Aufteilung:** -- Journal bleibt übersichtlich und wächst linear -- Status und Todos sind immer aktuell an einem Ort -- Klare Trennung zwischen Historie und Planung -- Einfacher für neue Entwickler einzusteigen - -### 2025-06-09: Nginx Config angepasst - -**Änderung:** -- proxy_pass für License Server von `https://license-server:8443` auf `http://license-server:8443` geändert -- `proxy_ssl_verify off` entfernt (nicht mehr nötig bei HTTP) -- WebSocket-Support hinzugefügt (für zukünftige Features) - -**Grund:** -- License Server läuft intern auf HTTP (wie Admin Panel) -- SSL-Termination erfolgt nur am Nginx -- Vereinfachte Konfiguration ohne doppelte SSL-Verschlüsselung - -**Hinweis:** -Docker-Container müssen neu gestartet werden, damit die Änderung wirksam wird: -```bash -docker-compose down -docker-compose up -d -``` -- `v2_adminpanel/templates/profile.html` - Komplett überarbeitet -- `v2_adminpanel/templates/setup_2fa.html` - Neues Step-by-Step Design -- `v2_adminpanel/templates/backup_codes.html` - Modernisiertes Layout - -**Status:** ✅ Abgeschlossen - Login funktioniert, UI im konsistenten Design - -### 2025-06-09: Lizenzschlüssel-Format geändert - -**Änderung:** -- Altes Format: `AF-YYYYMMFT-XXXX-YYYY-ZZZZ` (z.B. AF-202506F-V55Y-9DWE-GL5G) -- Neues Format: `AF-F-YYYYMM-XXXX-YYYY-ZZZZ` (z.B. AF-F-202506-V55Y-9DWE-GL5G) - -**Vorteile:** -- Klarere Struktur mit separatem Typ-Indikator -- Einfacher zu lesen und zu verstehen -- Typ (F/T) sofort im zweiten Block erkennbar - -**Geänderte Dateien:** -- `v2_adminpanel/app.py`: - - `generate_license_key()` - Generiert Keys im neuen Format - - `validate_license_key()` - Validiert Keys mit neuem Regex-Pattern -- `v2_adminpanel/templates/index.html`: - - Placeholder und Pattern für Input-Feld angepasst - - JavaScript charAt() Position für Typ-Prüfung korrigiert -- `v2_adminpanel/templates/batch_form.html`: - - Vorschau-Format für Batch-Generierung angepasst - -**Hinweis:** Alte Keys im bisherigen Format bleiben ungültig. Bei Bedarf könnte eine Migration oder Dual-Support implementiert werden. - -**Status:** ✅ Implementiert - -### 2025-06-09: Datenbank-Migration der Lizenzschlüssel - -**Durchgeführt:** -- Alle bestehenden Lizenzschlüssel in der Datenbank auf das neue Format migriert -- 18 Lizenzschlüssel erfolgreich konvertiert (16 Full, 2 Test) - -**Migration:** -- Von: `AF-YYYYMMFT-XXXX-YYYY-ZZZZ` -- Nach: `AF-F-YYYYMM-XXXX-YYYY-ZZZZ` - -**Beispiele:** -- Alt: `AF-202506F-V55Y-9DWE-GL5G` -- Neu: `AF-F-202506-V55Y-9DWE-GL5G` - -**Geänderte Dateien:** -- `v2_adminpanel/migrate_license_keys.sql` - Migrations-Script (temporär) -- `v2_adminpanel/fix_license_keys.sql` - Korrektur-Script (temporär) - -**Status:** ✅ Alle Lizenzschlüssel erfolgreich migriert - -### 2025-06-09: Kombinierte Kunden-Lizenz-Ansicht implementiert - -**Problem:** -- Umständliche Navigation zwischen Kunden- und Lizenzseiten -- Viel Hin-und-Her-Springen bei der Verwaltung -- Kontext-Verlust beim Wechseln zwischen Ansichten - -**Lösung:** -Master-Detail View mit 2-Spalten Layout implementiert - -**Phase 1-3 abgeschlossen:** -1. **Backend-Implementierung:** - - Neue Route `/customers-licenses` für kombinierte Ansicht - - API-Endpoints für AJAX: `/api/customer//licenses`, `/api/customer//quick-stats` - - API-Endpoint `/api/license//quick-edit` für Inline-Bearbeitung - - Optimierte SQL-Queries mit JOIN für Performance - -2. **Template-Erstellung:** - - Neues Template `customers_licenses.html` mit Master-Detail Layout - - Links: Kundenliste (30%) mit Suchfeld - - Rechts: Lizenzen des ausgewählten Kunden (70%) - - Responsive Design (Mobile: untereinander) - - JavaScript für dynamisches Laden ohne Seitenreload - - Keyboard-Navigation (↑↓ für Kundenwechsel) - -3. **Integration:** - - Dashboard: Neuer Button "Kunden & Lizenzen" - - Customers-Seite: Link zur kombinierten Ansicht - - Licenses-Seite: Link zur kombinierten Ansicht - - Lizenz-Erstellung: Unterstützung für vorausgewählten Kunden - - API /api/customers erweitert für Einzelabruf per ID - -**Features:** -- Live-Suche in Kundenliste -- Quick-Actions: Copy License Key, Toggle Status -- Modal für neue Lizenz direkt aus Kundenansicht -- URL-Update ohne Reload für Bookmarking -- Loading-States während AJAX-Calls -- Visuelles Feedback (aktiver Kunde hervorgehoben) - -**Noch ausstehend:** -- Phase 4: Inline-Edit für Lizenzdetails -- Phase 5: Erweiterte Error-Handling und Polish - -**Geänderte Dateien:** -- `v2_adminpanel/app.py` - Neue Routen und API-Endpoints -- `v2_adminpanel/templates/customers_licenses.html` - Neues Template -- `v2_adminpanel/templates/dashboard.html` - Neuer Button -- `v2_adminpanel/templates/customers.html` - Link zur kombinierten Ansicht -- `v2_adminpanel/templates/licenses.html` - Link zur kombinierten Ansicht -- `v2_adminpanel/templates/index.html` - Unterstützung für preselected_customer_id - -**Status:** ✅ Grundfunktionalität implementiert und funktionsfähig - -### 2025-06-09: Kombinierte Ansicht - Fertigstellung und TODOs aktualisiert - -**Abgeschlossen:** -- Phase 1-3 der kombinierten Kunden-Lizenz-Ansicht vollständig implementiert -- Master-Detail Layout funktioniert einwandfrei -- AJAX-basiertes Laden ohne Seitenreload -- Keyboard-Navigation mit Pfeiltasten -- Quick-Actions für Copy und Toggle Status -- Integration in alle relevanten Seiten - -**THE_ROAD_SO_FAR.md aktualisiert:** -- Kombinierte Ansicht als "Erledigt" markiert -- Von "In Arbeit" zu "Abgeschlossen" verschoben -- Status dokumentiert - -**Verbesserung gegenüber vorher:** -- Kein Hin-und-Her-Springen mehr zwischen Seiten -- Kontext bleibt erhalten beim Arbeiten mit Kunden -- Schnellere Navigation und bessere Übersicht -- Deutlich verbesserte User Experience - -**Optional für später (Phase 4-5):** -- Inline-Edit für weitere Felder -- Erweiterte Quick-Actions -- Session-basierte Filter-Persistenz - -Die Hauptproblematik der umständlichen Navigation ist damit gelöst! - -### 2025-06-09: Test-Flag für Lizenzen implementiert - -**Ziel:** -- Klare Trennung zwischen Testdaten und echten Produktivdaten -- Testdaten sollen von der Software ignoriert werden können -- Bessere Übersicht im Admin Panel - -**Durchgeführte Änderungen:** - -1. **Datenbank-Schema (init.sql):** - - Neue Spalte `is_test BOOLEAN DEFAULT FALSE` zur `licenses` Tabelle hinzugefügt - - Migration für bestehende Daten: Alle werden als `is_test = TRUE` markiert - - Index `idx_licenses_is_test` für bessere Performance - -2. **Backend (app.py):** - - Dashboard-Queries filtern Testdaten mit `WHERE is_test = FALSE` aus - - Lizenz-Erstellung: Neues Checkbox-Feld für Test-Markierung - - Lizenz-Bearbeitung: Test-Status kann geändert werden - - Export: Optional mit/ohne Testdaten (`?include_test=true`) - - Bulk-Operationen: Nur auf Live-Daten anwendbar - - Neue Filter in Lizenzliste: "🧪 Testdaten" und "🚀 Live-Daten" - -3. **Frontend Templates:** - - **index.html**: Checkbox "Als Testdaten markieren" bei Lizenzerstellung - - **edit_license.html**: Checkbox zum Ändern des Test-Status - - **licenses.html**: Badge 🧪 für Testdaten, neue Filteroptionen - - **dashboard.html**: Info-Box zeigt Anzahl der Testdaten - - **batch_form.html**: Option für Batch-Test-Lizenzen - -4. **Audit-Log Integration:** - - `is_test` Feld wird bei CREATE/UPDATE geloggt - - Nachvollziehbarkeit von Test/Live-Status-Änderungen - -**Technische Details:** -- Testdaten werden in allen Statistiken ausgefiltert -- License Server API wird Lizenzen mit `is_test = TRUE` ignorieren -- Resource Pool bleibt unverändert (kann Test- und Live-Ressourcen verwalten) - -**Migration der bestehenden Daten:** -```sql -UPDATE licenses SET is_test = TRUE; -- Alle aktuellen Daten sind Testdaten -``` - -**Status:** ✅ Implementiert - -### 2025-06-09: Test-Flag für Kunden und Resource Pools erweitert - -**Ziel:** -- Konsistentes Test-Daten-Management über alle Entitäten -- Kunden und Resource Pools können ebenfalls als Testdaten markiert werden -- Automatische Verknüpfung: Test-Kunde → Test-Lizenzen → Test-Ressourcen - -**Durchgeführte Änderungen:** - -1. **Datenbank-Schema erweitert:** - - `customers.is_test BOOLEAN DEFAULT FALSE` hinzugefügt - - `resource_pools.is_test BOOLEAN DEFAULT FALSE` hinzugefügt - - Indizes für bessere Performance erstellt - - Migrations in init.sql integriert - -2. **Backend (app.py) - Erweiterte Logik:** - - Dashboard: Separate Zähler für Test-Kunden und Test-Ressourcen - - Kunde-Erstellung: Erbt Test-Status von Lizenz - - Test-Kunde erzwingt Test-Lizenzen - - Resource-Zuweisung: Test-Lizenzen bekommen nur Test-Ressourcen - - Customer-Management mit is_test Filter - -3. **Frontend Updates:** - - **customers.html**: 🧪 Badge für Test-Kunden - - **edit_customer.html**: Checkbox für Test-Status - - **dashboard.html**: Erweiterte Test-Statistik (Lizenzen, Kunden, Ressourcen) - -4. **Geschäftslogik:** - - Wenn neuer Kunde bei Test-Lizenz erstellt wird → automatisch Test-Kunde - - Wenn Test-Kunde gewählt wird → Lizenz automatisch als Test markiert - - Resource Pool Allocation prüft Test-Status für korrekte Zuweisung - -**Migration der bestehenden Daten:** -```sql -UPDATE customers SET is_test = TRUE; -- 5 Kunden -UPDATE resource_pools SET is_test = TRUE; -- 20 Ressourcen -``` - -**Technische Details:** -- Konsistente Test/Live-Trennung über alle Ebenen -- Dashboard-Statistiken zeigen nur Live-Daten -- Test-Ressourcen werden nur Test-Lizenzen zugewiesen -- Alle bestehenden Daten sind jetzt als Test markiert - -**Status:** ✅ Vollständig implementiert - -### 2025-06-09 (17:20 - 18:13): Kunden-Lizenz-Verwaltung konsolidiert - -**Problem:** -- Kombinierte Ansicht `/customers-licenses` hatte Formatierungs- und Funktionsprobleme -- Kunden wurden nicht angezeigt -- Bootstrap Icons fehlten -- JavaScript-Fehler beim Modal -- Inkonsistentes Design im Vergleich zu anderen Seiten -- Testkunden-Filter wurde beim Navigieren nicht beibehalten - -**Durchgeführte Änderungen:** - -1. **Frontend-Fixes (base.html):** - - Bootstrap Icons CSS hinzugefügt: `bootstrap-icons@1.11.3/font/bootstrap-icons.min.css` - - Bootstrap JavaScript Bundle bereits vorhanden, Reihenfolge optimiert - -2. **customers_licenses.html komplett überarbeitet:** - - Container-Klasse von `container-fluid` auf `container py-5` geändert - - Emojis und Button-Styling vereinheitlicht (👥 Kunden & Lizenzen) - - Export-Dropdown wie in anderen Ansichten implementiert - - Card-Styling mit Schatten für einheitliches Design - - Checkbox "Testkunden anzeigen" mit Status-Beibehaltung - - JavaScript-Funktionen korrigiert: - - copyToClipboard mit event.currentTarget - - showNewLicenseModal mit Bootstrap Modal - - Header-Update beim AJAX-Kundenwechsel - - URL-Parameter `show_test` wird überall beibehalten - -3. **Backend-Anpassungen (app.py):** - - customers_licenses Route: Optional Testkunden anzeigen mit `show_test` Parameter - - Redirects von `/customers` und `/licenses` auf `/customers-licenses` implementiert - - Alte Route-Funktionen entfernt (kein toter Code mehr) - - edit_license und edit_customer: Redirects behalten show_test Parameter bei - - Dashboard-Links zeigen jetzt auf kombinierte Ansicht - -4. **Navigation optimiert:** - - Dashboard: Klick auf Kunden/Lizenzen-Statistik führt zur kombinierten Ansicht - - Alle Edit-Links behalten den show_test Parameter bei - - Konsistente User Experience beim Navigieren - -**Technische Details:** -- AJAX-Loading für dynamisches Laden der Lizenzen -- Keyboard-Navigation (↑↓) für Kundenliste -- Responsive Design mit Bootstrap Grid -- Modal-Dialoge für Bestätigungen -- Live-Suche in der Kundenliste - -**Resultat:** -- ✅ Einheitliches Design mit anderen Admin-Panel-Seiten -- ✅ Alle Funktionen arbeiten korrekt -- ✅ Testkunden-Filter bleibt erhalten -- ✅ Keine redundanten Views mehr -- ✅ Zentrale Verwaltung für Kunden und Lizenzen - -**Status:** ✅ Vollständig implementiert - -### 2025-06-09: Test-Daten Checkbox Persistenz implementiert - -**Problem:** -- Die "Testkunden anzeigen" Checkbox in `/customers-licenses` verlor ihren Status beim Navigieren zwischen Seiten -- Wenn Benutzer zu anderen Seiten (Resources, Audit Log, etc.) wechselten und zurückkehrten, war die Checkbox wieder deaktiviert -- Benutzer mussten die Checkbox jedes Mal neu aktivieren, was umständlich war - -**Lösung:** -- Globale JavaScript-Funktion `preserveShowTestParameter()` in base.html implementiert -- Die Funktion prüft beim Laden jeder Seite, ob `show_test=true` in der URL ist -- Wenn ja, wird dieser Parameter automatisch an alle internen Links angehängt -- Backend-Route `/create` wurde angepasst, um den Parameter bei Redirects beizubehalten - -**Technische Details:** -1. **base.html** - JavaScript-Funktion hinzugefügt: - - Läuft beim `DOMContentLoaded` Event - - Findet alle Links die mit "/" beginnen - - Fügt `show_test=true` Parameter hinzu wenn nicht bereits vorhanden - - Überspringt Fragment-Links (#) und Links die bereits den Parameter haben - -2. **app.py** - Route-Anpassung: - - `/create` Route behält jetzt `show_test` Parameter bei Redirects bei - - Andere Routen (edit_license, edit_customer) behalten Parameter bereits bei - -**Vorteile:** -- ✅ Konsistente User Experience beim Navigieren -- ✅ Keine manuelle Anpassung aller Links nötig -- ✅ Funktioniert automatisch für alle zukünftigen Links -- ✅ Minimaler Code-Overhead - -**Geänderte Dateien:** -- `v2_adminpanel/templates/base.html` -- `v2_adminpanel/app.py` - -**Status:** ✅ Vollständig implementiert - -### 2025-06-09: Bearbeiten-Button Fehler behoben - -**Problem:** -- Der "Bearbeiten" Button neben dem Kundennamen in der `/customers-licenses` Ansicht verursachte einen Internal Server Error -- Die URL-Konstruktion war fehlerhaft wenn kein `show_test` Parameter vorhanden war -- Die edit_customer.html Template hatte falsche Array-Indizes und veraltete Links - -**Ursache:** -1. Die href-Attribute wurden falsch konstruiert: - - Alt: `/customer/edit/ID{% if show_test %}?ref=customers-licenses&show_test=true{% endif %}` - - Problem: Ohne show_test fehlte das `?ref=customers-licenses` komplett - -2. Die SQL-Abfrage in edit_customer() holte nur 4 Felder, aber das Template erwartete 5: - - Query: `SELECT id, name, email, is_test` - - Template erwartete: `customer[3]` = created_at und `customer[4]` = is_test - -3. Veraltete Links zu `/customers` statt `/customers-licenses` - -**Lösung:** -1. URL-Konstruktion korrigiert in beiden Fällen: - - Neu: `/customer/edit/ID?ref=customers-licenses{% if show_test %}&show_test=true{% endif %}` - -2. SQL-Query erweitert um created_at: - - Neu: `SELECT id, name, email, created_at, is_test` - -3. Template-Indizes korrigiert: - - is_test Checkbox nutzt jetzt `customer[4]` - -4. Navigation-Links aktualisiert: - - Alle Links zeigen jetzt auf `/customers-licenses` mit show_test Parameter - -**Geänderte Dateien:** -- `v2_adminpanel/templates/customers_licenses.html` (Zeilen 103 und 295) -- `v2_adminpanel/app.py` (edit_customer Route) -- `v2_adminpanel/templates/edit_customer.html` - -**Status:** ✅ Behoben - -### 2025-06-09: Unnötigen Lizenz-Erstellungs-Popup entfernt - -**Änderung:** -- Der Bestätigungs-Popup "Möchten Sie eine neue Lizenz für KUNDENNAME erstellen?" wurde entfernt -- Klick auf "Neue Lizenz" Button führt jetzt direkt zur Lizenzerstellung - -**Technische Details:** -- Modal-HTML komplett entfernt -- `showNewLicenseModal()` Funktion vereinfacht - navigiert jetzt direkt zu `/create?customer_id=X` -- URL-Parameter (wie `show_test`) werden dabei beibehalten - -**Vorteile:** -- ✅ Ein Klick weniger für Benutzer -- ✅ Schnellerer Workflow -- ✅ Weniger Code zu warten - -**Geänderte Dateien:** -- `v2_adminpanel/templates/customers_licenses.html` - -**Status:** ✅ Implementiert - -### 2025-06-09: Testkunden-Checkbox bleibt jetzt bei Lizenz/Kunden-Bearbeitung erhalten - -**Problem:** -- Bei "Lizenz bearbeiten" ging der "Testkunden anzeigen" Haken verloren beim Zurückkehren -- Die Navigation-Links in edit_license.html zeigten auf `/licenses` statt `/customers-licenses` -- Der show_test Parameter wurde nur über den unsicheren Referrer übertragen - -**Lösung:** -1. **Navigation-Links korrigiert**: - - Alle Links zeigen jetzt auf `/customers-licenses` mit show_test Parameter - - Betrifft: "Zurück zur Übersicht" und "Abbrechen" Buttons - -2. **Hidden Form Field hinzugefügt**: - - Sowohl in edit_license.html als auch edit_customer.html - - Überträgt den show_test Parameter sicher beim POST - -3. **Route-Logik verbessert**: - - Parameter wird aus Form-Daten ODER GET-Parametern gelesen - - Nicht mehr auf unsicheren Referrer angewiesen - - Funktioniert sowohl bei Speichern als auch Abbrechen - -**Technische Details:** -- Templates prüfen `request.args.get('show_test')` für Navigation -- Hidden Input: `` -- Routes: `show_test = request.form.get('show_test') or request.args.get('show_test')` - -**Geänderte Dateien:** -- `v2_adminpanel/templates/edit_license.html` -- `v2_adminpanel/templates/edit_customer.html` -- `v2_adminpanel/app.py` (edit_license und edit_customer Routen) - -**Status:** ✅ Vollständig implementiert - -### 2025-06-09 22:02: Konsistente Sortierung bei Status-Toggle - -**Problem:** -- Beim Klicken auf den An/Aus-Knopf (Status-Toggle) in der Kunden & Lizenzen Ansicht änderte sich die Reihenfolge der Lizenzen -- Dies war verwirrend für Benutzer, da die Position der gerade bearbeiteten Lizenz springen konnte - -**Ursache:** -- Die Sortierung `ORDER BY l.created_at DESC` war nicht stabil genug -- Bei gleichem Erstellungszeitpunkt konnte die Datenbank die Reihenfolge inkonsistent zurückgeben - -**Lösung:** -- Sekundäres Sortierkriterium hinzugefügt: `ORDER BY l.created_at DESC, l.id DESC` -- Dies stellt sicher, dass bei gleichem Erstellungsdatum nach ID sortiert wird -- Die Reihenfolge bleibt jetzt konsistent, auch nach Status-Änderungen - -**Geänderte Dateien:** -- `v2_adminpanel/app.py`: - - Zeile 2278: `/customers-licenses` Route - - Zeile 2319: `/api/customer//licenses` API-Route - -### 2025-06-10 00:01: Verbesserte Integration zwischen Kunden & Lizenzen und Resource Pool - -**Problem:** -- Umständliche Navigation zwischen Kunden & Lizenzen und Resource Pool Bereichen -- Keine direkte Verbindung zwischen beiden Ansichten -- Benutzer mussten ständig zwischen verschiedenen Seiten hin- und herspringen - -**Implementierte Lösung - 5 Phasen:** - -1. **Phase 1: Ressourcen-Details in Kunden & Lizenzen Ansicht** - - API `/api/customer/{id}/licenses` erweitert um konkrete Ressourcen-Informationen - - Neue API `/api/license/{id}/resources` für detaillierte Ressourcen einer Lizenz - - Anzeige der zugewiesenen Ressourcen mit Info-Buttons und Modal-Dialogen - - Klickbare Links zu Ressourcen-Details im Resource Pool - -2. **Phase 2: Quick-Actions für Ressourcenverwaltung** - - "Ressourcen verwalten" Button (Zahnrad-Icon) bei jeder Lizenz - - Modal mit Übersicht aller zugewiesenen Ressourcen - - Vorbereitung für Quarantäne-Funktionen und Ressourcen-Austausch - -3. **Phase 3: Ressourcen-Preview bei Lizenzerstellung** - - Live-Anzeige verfügbarer Ressourcen beim Ändern der Anzahl - - Erweiterte Verfügbarkeitsanzeige mit Badges (OK/Niedrig/Kritisch) - - Warnungen bei niedrigem Bestand mit visuellen Hinweisen - - Fortschrittsbalken zur Visualisierung der Verfügbarkeit - -4. **Phase 4: Dashboard-Integration** - - Resource Pool Widget mit erweiterten Links - - Kritische Warnungen bei < 50 Ressourcen mit "Auffüllen" Button - - Direkte Navigation zu gefilterten Ansichten (nach Typ/Status) - - Verbesserte visuelle Darstellung mit Tooltips - -5. **Phase 5: Bidirektionale Navigation** - - Von Resource Pool: Links zu Kunden/Lizenzen bei zugewiesenen Ressourcen - - "Zurück zu Kunden" Button wenn von Kunden & Lizenzen kommend - - Navigation-Links im Dashboard für schnellen Zugriff - - SQL-Query erweitert um customer_id für direkte Verlinkung - -**Technische Details:** -- JavaScript-Funktionen für Modal-Dialoge und Ressourcen-Details -- Erweiterte SQL-Queries mit JOINs für Ressourcen-Informationen -- Bootstrap 5 Tooltips und Modals für bessere UX -- Globale Variable `currentLicenses` für Caching der Lizenzdaten - -**Geänderte Dateien:** -- `v2_adminpanel/app.py` - Neue APIs und erweiterte Queries -- `v2_adminpanel/templates/customers_licenses.html` - Ressourcen-Details und Modals -- `v2_adminpanel/templates/index.html` - Erweiterte Verfügbarkeitsanzeige -- `v2_adminpanel/templates/dashboard.html` - Verbesserte Resource Pool Integration -- `v2_adminpanel/templates/resources.html` - Bidirektionale Navigation - -**Status:** ✅ Alle 5 Phasen erfolgreich implementiert - -### 2025-06-10 00:15: IP-Adressen-Erfassung hinter Reverse Proxy korrigiert - -**Problem:** -- Flask-App erfasste nur die Docker-interne IP-Adresse von Nginx (172.19.0.5) -- Echte Client-IPs wurden nicht in Audit-Logs und Login-Attempts gespeichert -- Nginx setzte die Header korrekt, aber Flask las sie nicht aus - -**Ursache:** -- Flask verwendet standardmäßig nur `request.remote_addr` -- Dies gibt bei einem Reverse Proxy nur die Proxy-IP zurück -- Die Header `X-Real-IP` und `X-Forwarded-For` wurden ignoriert - -**Lösung:** -1. **ProxyFix Middleware** hinzugefügt für korrekte Header-Verarbeitung -2. **get_client_ip() Funktion** angepasst: - - Prüft zuerst `X-Real-IP` Header - - Dann `X-Forwarded-For` Header (nimmt erste IP bei mehreren) - - Fallback auf `request.remote_addr` -3. **Debug-Logging** für IP-Erfassung hinzugefügt -4. **Alle `request.remote_addr` Aufrufe** durch `get_client_ip()` ersetzt - -**Technische Details:** -```python -# ProxyFix für korrekte IP-Adressen -app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) - -# Verbesserte IP-Erfassung -def get_client_ip(): - if request.headers.get('X-Real-IP'): - return request.headers.get('X-Real-IP') - elif request.headers.get('X-Forwarded-For'): - return request.headers.get('X-Forwarded-For').split(',')[0].strip() - else: - return request.remote_addr -``` - -**Geänderte Dateien:** -- `v2_adminpanel/app.py` - ProxyFix und verbesserte IP-Erfassung - -**Status:** ✅ Implementiert - Neue Aktionen erfassen jetzt echte Client-IPs - -### 2025-06-10 00:30: Docker ENV Legacy-Format Warnungen behoben - -**Problem:** -- Docker Build zeigte Warnungen: "LegacyKeyValueFormat: ENV key=value should be used" -- Veraltetes Format `ENV KEY VALUE` wurde in Dockerfiles verwendet - -**Lösung:** -- Alle ENV-Anweisungen auf neues Format `ENV KEY=VALUE` umgestellt -- Betraf hauptsächlich v2_postgres/Dockerfile mit 3 ENV-Zeilen - -**Geänderte Dateien:** -- `v2_postgres/Dockerfile` - ENV-Format modernisiert - -**Beispiel der Änderung:** -```dockerfile -# Alt (Legacy): -ENV LANG de_DE.UTF-8 -ENV LANGUAGE de_DE:de - -# Neu (Modern): -ENV LANG=de_DE.UTF-8 -ENV LANGUAGE=de_DE:de -``` - -**Status:** ✅ Alle Dockerfiles verwenden jetzt das moderne ENV-Format - +# v2-Docker Projekt Journal + +## Letzte Änderungen (06.01.2025) + +### Gerätelimit-Feature implementiert +- **Datenbank-Schema erweitert**: + - Neue Spalte `device_limit` in `licenses` Tabelle (Standard: 3, Range: 1-10) + - Neue Tabelle `device_registrations` für Hardware-ID Tracking + - Indizes für Performance-Optimierung hinzugefügt + +- **UI-Anpassungen**: + - Einzellizenz-Formular: Dropdown für Gerätelimit (1-10 Geräte) + - Batch-Formular: Gerätelimit pro Lizenz auswählbar + - Lizenz-Bearbeitung: Gerätelimit änderbar + - Lizenz-Anzeige: Zeigt aktive Geräte (z.B. "💻 2/3") + +- **Backend-Änderungen**: + - Lizenz-Erstellung speichert device_limit + - Batch-Erstellung berücksichtigt device_limit + - Lizenz-Update kann device_limit ändern + - API-Endpoints liefern Geräteinformationen + +- **Migration**: + - Skript `migrate_device_limit.sql` erstellt + - Setzt device_limit = 3 für alle bestehenden Lizenzen + +### Vollständig implementiert: +✅ Device Management UI (Geräte pro Lizenz anzeigen/verwalten) +✅ Device Validation Logic (Prüfung bei Geräte-Registrierung) +✅ API-Endpoints für Geräte-Registrierung/Deregistrierung + +### API-Endpoints: +- `GET /api/license//devices` - Listet alle Geräte einer Lizenz +- `POST /api/license//register-device` - Registriert ein neues Gerät +- `POST /api/license//deactivate-device/` - Deaktiviert ein Gerät + +### Features: +- Geräte-Registrierung mit Hardware-ID Validierung +- Automatische Prüfung des Gerätelimits +- Reaktivierung deaktivierter Geräte möglich +- Geräte-Verwaltung UI mit Modal-Dialog +- Anzeige von Gerätename, OS, IP, Registrierungsdatum +- Admin kann Geräte manuell deaktivieren + +--- + +## Projektübersicht +Lizenzmanagement-System für Social Media Account-Erstellungssoftware mit Docker-basierter Architektur. + +### Technische Anforderungen +- **Lokaler Betrieb**: Docker mit 4GB RAM und 40GB Speicher +- **Internet-Zugriff**: + - Admin Panel: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com + - API Server: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com +- **Datenbank**: PostgreSQL mit 2 Admin-Usern +- **Ziel**: PoC für spätere VPS-Migration + +--- + +## Best Practices für Produktiv-Migration + +### Passwort-Management +Für die Migration auf Hetzner/VPS müssen die Credentials sicher verwaltet werden: + +1. **Environment Variables erstellen:** + ```bash + # .env.example (ins Git Repository) + POSTGRES_USER=changeme + POSTGRES_PASSWORD=changeme + POSTGRES_DB=changeme + SECRET_KEY=generate-a-secure-key + ADMIN_USER_1=changeme + ADMIN_PASS_1=changeme + ADMIN_USER_2=changeme + ADMIN_PASS_2=changeme + + # .env (NICHT ins Git, auf Server erstellen) + POSTGRES_USER=produktiv_user + POSTGRES_PASSWORD=sicheres_passwort_min_20_zeichen + POSTGRES_DB=v2docker_prod + SECRET_KEY=generierter_64_zeichen_key + # etc. + ``` + +2. **Sichere Passwörter generieren:** + - Mindestens 20 Zeichen + - Mix aus Groß-/Kleinbuchstaben, Zahlen, Sonderzeichen + - Verschiedene Passwörter für Dev/Staging/Prod + - Password-Generator verwenden (z.B. `openssl rand -base64 32`) + +3. **Erweiterte Sicherheit (Optional):** + - HashiCorp Vault für zentrale Secret-Verwaltung + - Docker Secrets (für Docker Swarm) + - Cloud-Lösungen: AWS Secrets Manager, Azure Key Vault + +4. **Wichtige Checkliste:** + - [ ] `.env` in `.gitignore` aufnehmen + - [ ] Neue Credentials für Produktion generieren + - [ ] Backup der Credentials an sicherem Ort + - [ ] Regelmäßige Passwort-Rotation planen + - [ ] Keine Default-Passwörter verwenden + +--- + +## Änderungsprotokoll + +### 2025-06-06 - Journal erstellt +- Initialer Projektstand dokumentiert +- Aufgabenliste priorisiert +- Technische Anforderungen festgehalten + +### 2025-06-06 - UTF-8 Support implementiert +- Flask App Konfiguration für UTF-8 hinzugefügt (JSON_AS_ASCII=False) +- PostgreSQL Verbindung mit UTF-8 client_encoding +- HTML Forms mit accept-charset="UTF-8" +- Dockerfile mit deutschen Locale-Einstellungen (de_DE.UTF-8) +- PostgreSQL Container mit UTF-8 Initialisierung +- init.sql mit SET client_encoding = 'UTF8' + +**Geänderte Dateien:** +- v2_adminpanel/app.py +- v2_adminpanel/templates/index.html +- v2_adminpanel/init.sql +- v2_adminpanel/Dockerfile +- v2/docker-compose.yaml + +**Nächster Test:** +- Container neu bauen und starten +- Kundennamen mit Umlauten testen (z.B. "Müller GmbH", "Björn Schäfer") +- Email mit Umlauten testen + +### 2025-06-06 - Lizenzübersicht implementiert +- Neue Route `/licenses` für Lizenzübersicht +- SQL-Query mit JOIN zwischen licenses und customers +- Status-Berechnung (aktiv, läuft bald ab, abgelaufen) +- Farbcodierung für verschiedene Status +- Navigation zwischen Lizenz erstellen und Übersicht + +**Neue Features:** +- Anzeige aller Lizenzen mit Kundeninformationen +- Status-Anzeige basierend auf Ablaufdatum +- Unterscheidung zwischen Voll- und Testversion +- Responsive Tabelle mit Bootstrap +- Link von Dashboard zur Übersicht und zurück + +**Geänderte/Neue Dateien:** +- v2_adminpanel/app.py (neue Route hinzugefügt) +- v2_adminpanel/templates/licenses.html (neu erstellt) +- v2_adminpanel/templates/index.html (Navigation ergänzt) + +**Nächster Test:** +- Container neu starten +- Mehrere Lizenzen mit verschiedenen Ablaufdaten erstellen +- Lizenzübersicht unter /licenses aufrufen + +### 2025-06-06 - Lizenz bearbeiten/löschen implementiert +- Neue Routen für Bearbeiten und Löschen von Lizenzen +- Bearbeitungsformular mit vorausgefüllten Werten +- Aktiv/Inaktiv-Status kann geändert werden +- Lösch-Bestätigung per JavaScript confirm() +- Kunde kann nicht geändert werden (nur Lizenzdetails) + +**Neue Features:** +- `/license/edit/` - Bearbeitungsformular +- `/license/delete/` - Lizenz löschen (POST) +- Aktionen-Spalte in der Lizenzübersicht +- Buttons für Bearbeiten und Löschen +- Checkbox für Aktiv-Status + +**Geänderte/Neue Dateien:** +- v2_adminpanel/app.py (edit_license und delete_license Routen) +- v2_adminpanel/templates/licenses.html (Aktionen-Spalte hinzugefügt) +- v2_adminpanel/templates/edit_license.html (neu erstellt) + +**Sicherheit:** +- Login-Required für alle Aktionen +- POST-only für Löschvorgänge +- Bestätigungsdialog vor dem Löschen + +### 2025-06-06 - Kundenverwaltung implementiert +- Komplette CRUD-Funktionalität für Kunden +- Übersicht zeigt Anzahl aktiver/gesamter Lizenzen pro Kunde +- Kunden können nur gelöscht werden, wenn sie keine Lizenzen haben +- Bearbeitungsseite zeigt alle Lizenzen des Kunden + +**Neue Features:** +- `/customers` - Kundenübersicht mit Statistiken +- `/customer/edit/` - Kunde bearbeiten (Name, E-Mail) +- `/customer/delete/` - Kunde löschen (nur ohne Lizenzen) +- Navigation zwischen allen drei Hauptbereichen +- Anzeige der Kundenlizenzen beim Bearbeiten + +**Geänderte/Neue Dateien:** +- v2_adminpanel/app.py (customers, edit_customer, delete_customer Routen) +- v2_adminpanel/templates/customers.html (neu erstellt) +- v2_adminpanel/templates/edit_customer.html (neu erstellt) +- v2_adminpanel/templates/index.html (Navigation erweitert) +- v2_adminpanel/templates/licenses.html (Navigation erweitert) + +**Besonderheiten:** +- Lösch-Button ist deaktiviert, wenn Kunde Lizenzen hat +- Aktive Lizenzen werden separat gezählt (nicht abgelaufen + aktiv) +- UTF-8 Support für Kundennamen mit Umlauten + +### 2025-06-06 - Dashboard mit Statistiken implementiert +- Übersichtliches Dashboard als neue Startseite +- Statistik-Karten mit wichtigen Kennzahlen +- Listen für bald ablaufende und zuletzt erstellte Lizenzen +- Routing angepasst: Dashboard (/) und Lizenz erstellen (/create) + +**Neue Features:** +- Statistik-Karten: Kunden, Lizenzen gesamt, Aktive, Ablaufende +- Aufteilung nach Lizenztypen (Vollversion/Testversion) +- Aufteilung nach Status (Aktiv/Abgelaufen) +- Top 10 bald ablaufende Lizenzen mit Restlaufzeit +- Letzte 5 erstellte Lizenzen mit Status +- Hover-Effekt auf Statistik-Karten +- Einheitliche Navigation mit Dashboard-Link + +**Geänderte/Neue Dateien:** +- v2_adminpanel/app.py (dashboard() komplett überarbeitet, create_license() Route) +- v2_adminpanel/templates/dashboard.html (neu erstellt) +- v2_adminpanel/templates/index.html (Navigation erweitert) +- v2_adminpanel/templates/licenses.html (Navigation angepasst) +- v2_adminpanel/templates/customers.html (Navigation angepasst) + +**Dashboard-Inhalte:** +- 4 Hauptstatistiken als Karten +- Lizenztyp-Verteilung +- Status-Verteilung +- Warnung für bald ablaufende Lizenzen +- Übersicht der neuesten Aktivitäten + +### 2025-06-06 - Suchfunktion implementiert +- Volltextsuche für Lizenzen und Kunden +- Case-insensitive Suche mit LIKE-Operator +- Suchergebnisse mit Hervorhebung des Suchbegriffs +- Suche zurücksetzen Button + +**Neue Features:** +- **Lizenzsuche**: Sucht in Lizenzschlüssel, Kundenname und E-Mail +- **Kundensuche**: Sucht in Kundenname und E-Mail +- Suchformular mit autofocus für schnelle Eingabe +- Anzeige des aktiven Suchbegriffs +- Unterschiedliche Meldungen für leere Ergebnisse + +**Geänderte Dateien:** +- v2_adminpanel/app.py (licenses() und customers() mit Suchlogik erweitert) +- v2_adminpanel/templates/licenses.html (Suchformular hinzugefügt) +- v2_adminpanel/templates/customers.html (Suchformular hinzugefügt) + +**Technische Details:** +- GET-Parameter für Suche +- SQL LIKE mit LOWER() für Case-Insensitive Suche +- Wildcards (%) für Teilstring-Suche +- UTF-8 kompatibel für deutsche Umlaute + +### 2025-06-06 - Filter und Pagination implementiert +- Erweiterte Filteroptionen für Lizenzübersicht +- Pagination für große Datenmengen (20 Einträge pro Seite) +- Filter bleiben bei Seitenwechsel erhalten + +**Neue Features für Lizenzen:** +- **Filter nach Typ**: Alle, Vollversion, Testversion +- **Filter nach Status**: Alle, Aktiv, Läuft bald ab, Abgelaufen, Deaktiviert +- **Kombinierbar mit Suche**: Filter und Suche funktionieren zusammen +- **Pagination**: Navigation durch mehrere Seiten +- **Ergebnisanzeige**: Zeigt Anzahl gefilterter Ergebnisse + +**Neue Features für Kunden:** +- **Pagination**: 20 Kunden pro Seite +- **Seitennavigation**: Erste, Letzte, Vor, Zurück +- **Kombiniert mit Suche**: Suchparameter bleiben erhalten + +**Geänderte Dateien:** +- v2_adminpanel/app.py (licenses() und customers() mit Filter/Pagination erweitert) +- v2_adminpanel/templates/licenses.html (Filter-Formular und Pagination hinzugefügt) +- v2_adminpanel/templates/customers.html (Pagination hinzugefügt) + +**Technische Details:** +- SQL WHERE-Klauseln für Filter +- LIMIT/OFFSET für Pagination +- URL-Parameter bleiben bei Navigation erhalten +- Responsive Bootstrap-Komponenten + +### 2025-06-06 - Session-Tracking implementiert +- Neue Tabelle für Session-Verwaltung +- Anzeige aktiver und beendeter Sessions +- Manuelles Beenden von Sessions möglich +- Dashboard zeigt Anzahl aktiver Sessions + +**Neue Features:** +- **Sessions-Tabelle**: Speichert Session-ID, IP, User-Agent, Zeitstempel +- **Aktive Sessions**: Zeigt alle laufenden Sessions mit Inaktivitätszeit +- **Session-Historie**: Letzte 24 Stunden beendeter Sessions +- **Session beenden**: Admins können Sessions manuell beenden +- **Farbcodierung**: Grün (aktiv), Gelb (>5 Min inaktiv), Rot (lange inaktiv) + +**Geänderte/Neue Dateien:** +- v2_adminpanel/init.sql (sessions Tabelle hinzugefügt) +- v2_adminpanel/app.py (sessions() und end_session() Routen) +- v2_adminpanel/templates/sessions.html (neu erstellt) +- v2_adminpanel/templates/dashboard.html (Session-Statistik) +- Alle Templates (Session-Navigation hinzugefügt) + +**Technische Details:** +- Heartbeat-basiertes Tracking (last_heartbeat) +- Automatische Inaktivitätsberechnung +- Session-Dauer Berechnung +- Responsive Tabellen mit Bootstrap + +**Hinweis:** +Die Session-Daten werden erst gefüllt, wenn der License Server API implementiert ist und Clients sich verbinden. + +### 2025-06-06 - Export-Funktion implementiert +- CSV und Excel Export für Lizenzen und Kunden +- Formatierte Ausgabe mit deutschen Datumsformaten +- UTF-8 Unterstützung für Sonderzeichen + +**Neue Features:** +- **Lizenz-Export**: Alle Lizenzen mit Kundeninformationen +- **Kunden-Export**: Alle Kunden mit Lizenzstatistiken +- **Format-Optionen**: Excel (.xlsx) und CSV (.csv) +- **Deutsche Formatierung**: Datum als dd.mm.yyyy, Status auf Deutsch +- **UTF-8 Export**: Korrekte Kodierung für Umlaute +- **Export-Buttons**: Dropdown-Menüs in Lizenz- und Kundenübersicht + +**Geänderte Dateien:** +- v2_adminpanel/app.py (export_licenses() und export_customers() Routen) +- v2_adminpanel/requirements.txt (pandas und openpyxl hinzugefügt) +- v2_adminpanel/templates/licenses.html (Export-Dropdown hinzugefügt) +- v2_adminpanel/templates/customers.html (Export-Dropdown hinzugefügt) + +**Technische Details:** +- Pandas für Datenverarbeitung +- OpenPyXL für Excel-Export +- CSV mit Semikolon-Trennung für deutsche Excel-Kompatibilität +- Automatische Spaltenbreite in Excel +- BOM für UTF-8 CSV (Excel-Kompatibilität) + +### 2025-06-06 - Audit-Log implementiert +- Vollständiges Änderungsprotokoll für alle Aktionen +- Filterbare Übersicht mit Pagination +- Detaillierte Anzeige von Änderungen + +**Neue Features:** +- **Audit-Log-Tabelle**: Speichert alle Änderungen mit Zeitstempel, Benutzer, IP +- **Protokollierte Aktionen**: CREATE, UPDATE, DELETE, LOGIN, LOGOUT, EXPORT +- **JSON-Speicherung**: Alte und neue Werte als JSONB für flexible Abfragen +- **Filter-Optionen**: Nach Benutzer, Aktion und Entität +- **Detail-Anzeige**: Aufklappbare Änderungsdetails +- **Navigation**: Audit-Link in allen Templates + +**Geänderte/Neue Dateien:** +- v2_adminpanel/init.sql (audit_log Tabelle mit Indizes) +- v2_adminpanel/app.py (log_audit() Funktion und audit_log() Route) +- v2_adminpanel/templates/audit_log.html (neu erstellt) +- Alle Templates (Audit-Navigation hinzugefügt) + +**Technische Details:** +- JSONB für strukturierte Datenspeicherung +- Performance-Indizes auf timestamp, username und entity +- Farbcodierung für verschiedene Aktionen +- 50 Einträge pro Seite mit Pagination +- IP-Adresse und User-Agent Tracking + +### 2025-06-06 - PostgreSQL UTF-8 Locale konfiguriert +- Eigenes PostgreSQL Dockerfile für deutsche Locale +- Sicherstellung der UTF-8 Unterstützung auf Datenbankebene + +**Neue Features:** +- **PostgreSQL Dockerfile**: Installiert deutsche Locale (de_DE.UTF-8) +- **Locale-Umgebungsvariablen**: LANG, LANGUAGE, LC_ALL gesetzt +- **Docker Compose Update**: Verwendet jetzt eigenes PostgreSQL-Image + +**Neue Dateien:** +- v2_postgres/Dockerfile (neu erstellt) + +**Geänderte Dateien:** +- v2/docker-compose.yaml (postgres Service nutzt jetzt build statt image) + +**Technische Details:** +- 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 + +### 2025-06-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 + +### 2025-06-07 - HTTPS/SSL und Internet-Zugriff implementiert +- Nginx Reverse Proxy für externe Erreichbarkeit eingerichtet +- SSL-Zertifikate von IONOS mit vollständiger Certificate Chain integriert +- Netzwerkkonfiguration für feste IP-Adresse +- DynDNS und Port-Forwarding konfiguriert + +**Neue Features:** +- **Nginx Reverse Proxy**: Leitet HTTPS-Anfragen an Container weiter +- **SSL-Zertifikate**: Wildcard-Zertifikat von IONOS für *.z5m7q9dk3ah2v1plx6ju.com +- **Certificate Chain**: Server-, Intermediate- und Root-Zertifikate kombiniert +- **Subdomain-Routing**: admin-panel-undso und api-software-undso +- **Port-Forwarding**: FRITZ!Box 443 → 192.168.178.88 +- **Feste IP**: Windows-PC auf 192.168.178.88 konfiguriert + +**Neue/Geänderte Dateien:** +- v2_nginx/nginx.conf (Reverse Proxy Konfiguration) +- v2_nginx/Dockerfile (Nginx Container mit SSL) +- v2_nginx/ssl/fullchain.pem (Certificate Chain) +- v2_nginx/ssl/privkey.pem (Private Key) +- v2/docker-compose.yaml (nginx Service hinzugefügt) +- set-static-ip.ps1 (PowerShell Script für feste IP) +- reset-to-dhcp.ps1 (PowerShell Script für DHCP) + +**Technische Details:** +- SSL-Termination am Nginx Reverse Proxy +- Backend-Kommunikation über Docker-internes Netzwerk +- Admin-Panel nur noch über Nginx erreichbar (Port 443 nicht mehr exposed) +- License-Server behält externen Port 8443 für direkte API-Zugriffe +- Intermediate Certificates aus ZIP extrahiert und korrekt verkettet + +**Zugangsdaten:** +- Admin-Panel: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com +- Benutzer 1: rac00n +- Benutzer 2: w@rh@mm3r + +**Status:** +- ✅ Admin-Panel extern erreichbar ohne SSL-Warnungen +- ✅ Reverse Proxy funktioniert +- ✅ SSL-Zertifikate korrekt konfiguriert +- ✅ Netzwerk-Setup abgeschlossen + +### 2025-06-07 - Projekt-Cleanup durchgeführt +- Redundante und überflüssige Dateien entfernt +- Projektstruktur verbessert und organisiert + +**Durchgeführte Änderungen:** +1. **Entfernte Dateien:** + - v2_adminpanel/templates/.env (Duplikat der Haupt-.env) + - v2_postgreSQL/ (leeres Verzeichnis) + - SSL-Zertifikate aus Root-Verzeichnis (7 Dateien) + - Ungenutzer `json` Import aus app.py + +2. **Organisatorische Verbesserungen:** + - PowerShell-Scripts in neuen `scripts/` Ordner verschoben + - SSL-Zertifikate nur noch in v2_nginx/ssl/ + - Keine Konfigurationsdateien mehr in Template-Verzeichnissen + +**Technische Details:** +- Docker-Container wurden gestoppt und nach Cleanup neu gestartet +- Alle Services laufen wieder normal +- Keine funktionalen Änderungen, nur Struktur-Verbesserungen + +**Ergebnis:** +- Verbesserte Projektstruktur +- Erhöhte Sicherheit (keine SSL-Zertifikate im Root) +- Klarere Dateiorganisation + +### 2025-06-07 - SSL "Nicht sicher" Problem behoben +- Chrome-Warnung trotz gültigem Zertifikat analysiert und behoben +- Ursache: Selbstsigniertes Zertifikat in der Admin Panel Flask-App + +**Durchgeführte Änderungen:** +1. **Admin Panel Konfiguration (app.py):** + - Von HTTPS mit selbstsigniertem Zertifikat auf HTTP Port 5000 umgestellt + - `ssl_context='adhoc'` entfernt + - Flask läuft jetzt auf `0.0.0.0:5000` statt HTTPS + +2. **Dockerfile Anpassung (v2_adminpanel/Dockerfile):** + - EXPOSE Port von 443 auf 5000 geändert + - Container exponiert jetzt HTTP statt HTTPS + +3. **Nginx Konfiguration (nginx.conf):** + - proxy_pass von `https://admin-panel:443` auf `http://admin-panel:5000` geändert + - `proxy_ssl_verify off` entfernt (nicht mehr benötigt) + - Sicherheits-Header für beide Domains hinzugefügt: + - Strict-Transport-Security (HSTS) - erzwingt HTTPS für 1 Jahr + - X-Content-Type-Options - verhindert MIME-Type Sniffing + - X-Frame-Options - Schutz vor Clickjacking + - X-XSS-Protection - aktiviert XSS-Filter + - Referrer-Policy - kontrolliert Referrer-Informationen + +**Technische Details:** +- Externer Traffic nutzt weiterhin HTTPS mit gültigen IONOS-Zertifikaten +- Interne Kommunikation zwischen Nginx und Admin Panel läuft über HTTP (sicher im Docker-Netzwerk) +- Kein selbstsigniertes Zertifikat mehr in der Zertifikatskette +- SSL-Termination erfolgt ausschließlich am Nginx Reverse Proxy + +**Docker Neustart:** +- Container gestoppt (`docker-compose down`) +- Images neu gebaut (`docker-compose build`) +- Container neu gestartet (`docker-compose up -d`) +- Alle Services laufen normal + +**Ergebnis:** +- ✅ "Nicht sicher" Warnung in Chrome behoben +- ✅ Saubere SSL-Konfiguration ohne Mixed Content +- ✅ Verbesserte Sicherheits-Header implementiert +- ✅ Admin Panel zeigt jetzt grünes Schloss-Symbol + +### 2025-06-07 - Sicherheitslücke geschlossen: License Server Port +- Direkter Zugriff auf License Server Port 8443 entfernt +- Sicherheitsanalyse der exponierten Ports durchgeführt + +**Identifiziertes Problem:** +- License Server war direkt auf Port 8443 von außen erreichbar +- Umging damit die Nginx-Sicherheitsschicht und Security Headers +- Besonders kritisch, da nur Platzhalter ohne echte Sicherheit + +**Durchgeführte Änderung:** +- Port-Mapping für License Server in docker-compose.yaml entfernt +- Service ist jetzt nur noch über Nginx Reverse Proxy erreichbar +- Gleiche Sicherheitskonfiguration wie Admin Panel + +**Aktuelle Port-Exposition:** +- ✅ Nginx: Port 80/443 (benötigt für externen Zugriff) +- ✅ PostgreSQL: Keine Ports exponiert (gut) +- ✅ Admin Panel: Nur über Nginx erreichbar +- ✅ License Server: Nur über Nginx erreichbar (vorher direkt auf 8443) + +**Weitere identifizierte Sicherheitsthemen:** +1. Credentials im Klartext in .env Datei +2. SSL-Zertifikate im Repository gespeichert +3. License Server noch nicht implementiert + +**Empfehlung:** Docker-Container neu starten für Änderungsübernahme + +### 2025-06-07 - License Server Port 8443 wieder aktiviert +- Port 8443 für direkten Zugriff auf License Server wieder geöffnet +- Notwendig für Client-Software Lizenzprüfung + +**Begründung:** +- Client-Software benötigt direkten Zugriff für Lizenzprüfung +- Umgehung von möglichen Firewall-Blockaden auf Port 443 +- Weniger Latenz ohne Nginx-Proxy +- Flexibilität für verschiedene Client-Implementierungen + +**Konfiguration:** +- License Server erreichbar über: + - Direkt: Port 8443 (für Client-Software) + - Via Nginx: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com (für Browser/Tests) + +**Sicherheitshinweis:** +- Port 8443 ist wieder direkt exponiert +- License Server muss vor Produktivbetrieb implementiert werden mit: + - Eigener SSL-Konfiguration + - API-Key Authentifizierung + - Rate Limiting + - Input-Validierung + +**Status:** +- Port-Mapping in docker-compose.yaml wiederhergestellt +- Änderung erfordert Docker-Neustart + +### 2025-06-07 - Rate-Limiting und Brute-Force-Schutz implementiert +- Umfassender Schutz vor Login-Angriffen mit IP-Sperre +- Dashboard-Integration für Sicherheitsüberwachung + +**Implementierte Features:** +1. **Rate-Limiting System:** + - 5 Login-Versuche erlaubt, danach 24h IP-Sperre + - Progressive Fehlermeldungen (zufällig aus 5 lustigen Varianten) + - CAPTCHA nach 2 Fehlversuchen (Google reCAPTCHA v2 vorbereitet) + - E-Mail-Benachrichtigung bei Sperrung (vorbereitet, deaktiviert für PoC) + +2. **Timing-Attack Schutz:** + - Mindestens 1 Sekunde Antwortzeit bei allen Login-Versuchen + - Gleiche Antwortzeit bei richtigem/falschem Username + - Verhindert Username-Enumeration + +3. **Lustige Fehlermeldungen (zufällig):** + - "NOPE!" + - "ACCESS DENIED, TRY HARDER" + - "WRONG! 🚫" + - "COMPUTER SAYS NO" + - "YOU FAILED" + +4. **Dashboard-Sicherheitswidget:** + - Sicherheitslevel-Anzeige (NORMAL/ERHÖHT/KRITISCH) + - Anzahl gesperrter IPs + - Fehlversuche heute + - Letzte 5 Sicherheitsereignisse mit Details + +5. **IP-Verwaltung:** + - Übersicht aller gesperrten IPs + - Manuelles Entsperren möglich + - Login-Versuche zurücksetzen + - Detaillierte Informationen pro IP + +6. **Audit-Log Erweiterungen:** + - LOGIN_SUCCESS - Erfolgreiche Anmeldung + - LOGIN_FAILED - Fehlgeschlagener Versuch + - LOGIN_BLOCKED - IP wurde gesperrt + - UNBLOCK_IP - IP manuell entsperrt + - CLEAR_ATTEMPTS - Versuche zurückgesetzt + +**Neue/Geänderte Dateien:** +- v2_adminpanel/init.sql (login_attempts Tabelle) +- v2_adminpanel/app.py (Rate-Limiting Logik, neue Routen) +- v2_adminpanel/templates/login.html (Fehlermeldungs-Styling, CAPTCHA) +- v2_adminpanel/templates/dashboard.html (Sicherheitswidget) +- v2_adminpanel/templates/blocked_ips.html (neu - IP-Verwaltung) + +**Technische Details:** +- IP-Ermittlung berücksichtigt Proxy-Header (X-Forwarded-For) +- Fehlermeldungen mit Animation (shake-effect) +- Farbcodierung: Rot für Fehler, Lila für Sperre, Orange für CAPTCHA +- Automatische Bereinigung alter Einträge möglich + +**Sicherheitsverbesserungen:** +- Schutz vor Brute-Force-Angriffen +- Timing-Attack-Schutz implementiert +- IP-basierte Sperrung für 24 Stunden +- Audit-Trail für alle Sicherheitsereignisse + +**Hinweis für Produktion:** +- CAPTCHA-Keys müssen in .env konfiguriert werden +- E-Mail-Server für Benachrichtigungen einrichten +- Rate-Limits können über Konstanten angepasst werden + +### 2025-06-07 - Session-Timeout mit Live-Timer implementiert +- 5 Minuten Inaktivitäts-Timeout mit visueller Countdown-Anzeige +- Automatische Session-Verlängerung bei Benutzeraktivität + +**Implementierte Features:** +1. **Session-Timeout Backend:** + - Flask Session-Timeout auf 5 Minuten konfiguriert + - Heartbeat-Endpoint für Keep-Alive + - Automatisches Session-Update bei jeder Aktion + +2. **Live-Timer in der Navbar:** + - Countdown von 5:00 bis 0:00 + - Position: Zwischen Logo und Username + - Farbwechsel nach verbleibender Zeit: + - Grün: > 2 Minuten + - Gelb: 1-2 Minuten + - Rot: < 1 Minute + - Blinkend: < 30 Sekunden + +3. **Benutzerinteraktion:** + - Timer wird bei jeder Aktivität zurückgesetzt + - Tracking von: Klicks, Tastatureingaben, Mausbewegungen + - Automatischer Heartbeat bei Aktivität + - Warnung bei < 1 Minute mit "Session verlängern" Button + +4. **Base-Template System:** + - Neue base.html als Basis für alle Admin-Seiten + - Alle Templates (außer login.html) nutzen jetzt base.html + - Einheitliches Layout und Timer auf allen Seiten + +**Neue/Geänderte Dateien:** +- v2_adminpanel/app.py (Session-Konfiguration, Heartbeat-Endpoint) +- v2_adminpanel/templates/base.html (neu - Base-Template mit Timer) +- Alle anderen Templates aktualisiert für Template-Vererbung + +**Technische Details:** +- JavaScript-basierter Countdown-Timer +- AJAX-Heartbeat alle 5 Sekunden bei Aktivität +- LocalStorage für Tab-Synchronisation möglich +- Automatischer Logout bei 0:00 +- Fetch-Interceptor für automatische Session-Verlängerung + +**Sicherheitsverbesserung:** +- Automatischer Logout nach 5 Minuten Inaktivität +- Verhindert vergessene Sessions +- Visuelles Feedback für Session-Status + +### 2025-06-07 - Session-Timeout Bug behoben +- Problem: Session-Timeout funktionierte nicht korrekt - Session blieb länger als 5 Minuten aktiv +- Ursache: login_required Decorator aktualisierte last_activity bei JEDEM Request + +**Durchgeführte Änderungen:** +1. **login_required Decorator (app.py):** + - Prüft jetzt ob Session abgelaufen ist (5 Minuten seit last_activity) + - Aktualisiert last_activity NICHT mehr automatisch + - Führt AUTO_LOGOUT mit Audit-Log bei Timeout durch + - Speichert Username vor session.clear() für korrektes Logging + +2. **Heartbeat-Endpoint (app.py):** + - Geändert zu POST-only Endpoint + - Aktualisiert explizit last_activity wenn aufgerufen + - Wird nur bei aktiver Benutzerinteraktion aufgerufen + +3. **Frontend Timer (base.html):** + - Heartbeat wird als POST Request gesendet + - trackActivity() ruft extendSession() ohne vorheriges resetTimer() auf + - Timer wird erst nach erfolgreichem Heartbeat zurückgesetzt + - AJAX Interceptor ignoriert Heartbeat-Requests + +4. **Audit-Log Erweiterung:** + - Neue Aktion AUTO_LOGOUT hinzugefügt + - Orange Farbcodierung (#fd7e14) + - Zeigt Grund des Timeouts im Audit-Log + +**Ergebnis:** +- ✅ Session läuft nach exakt 5 Minuten Inaktivität ab +- ✅ Benutzeraktivität verlängert Session korrekt +- ✅ AUTO_LOGOUT wird im Audit-Log protokolliert +- ✅ Visueller Timer zeigt verbleibende Zeit + +### 2025-06-07 - Session-Timeout weitere Verbesserungen +- Zusätzliche Fixes nach Test-Feedback implementiert + +**Weitere durchgeführte Änderungen:** +1. **Fehlender Import behoben:** + - `flash` zu Flask-Imports hinzugefügt für Timeout-Warnmeldungen + +2. **Session-Cookie-Konfiguration erweitert (app.py):** + - SESSION_COOKIE_HTTPONLY = True (Sicherheit gegen XSS) + - SESSION_COOKIE_SECURE = False (intern HTTP, extern HTTPS via Nginx) + - SESSION_COOKIE_SAMESITE = 'Lax' (CSRF-Schutz) + - SESSION_COOKIE_NAME = 'admin_session' (eindeutiger Name) + - SESSION_REFRESH_EACH_REQUEST = False (verhindert automatische Verlängerung) + +3. **Session-Handling verbessert:** + - Entfernt: session.permanent = True aus login_required decorator + - Hinzugefügt: session.modified = True im Heartbeat für explizites Speichern + - Debug-Logging für Session-Timeout-Prüfung hinzugefügt + +4. **Nginx-Konfiguration:** + - Bereits korrekt konfiguriert für Heartbeat-Weiterleitung + - Proxy-Headers für korrekte IP-Weitergabe + +**Technische Details:** +- Flask-Session mit Filesystem-Backend nutzt jetzt korrekte Cookie-Einstellungen +- Session-Cookie wird nicht mehr automatisch bei jedem Request verlängert +- Explizite Session-Modifikation nur bei Heartbeat-Requests +- Debug-Logs zeigen Zeit seit letzter Aktivität für Troubleshooting + +**Status:** +- ✅ Session-Timeout-Mechanismus vollständig implementiert +- ✅ Debug-Logging für Session-Überwachung aktiv +- ✅ Cookie-Sicherheitseinstellungen optimiert + +### 2025-06-07 - CAPTCHA Backend-Validierung implementiert +- Google reCAPTCHA v2 Backend-Verifizierung hinzugefügt + +**Implementierte Features:** +1. **verify_recaptcha() Funktion (app.py):** + - Validiert CAPTCHA-Response mit Google API + - Fallback: Wenn RECAPTCHA_SECRET_KEY nicht konfiguriert, wird CAPTCHA übersprungen (für PoC) + - Timeout von 5 Sekunden für API-Request + - Error-Handling für Netzwerkfehler + - Logging für Debugging und Fehleranalyse + +2. **Login-Route Erweiterungen:** + - CAPTCHA wird nach 2 Fehlversuchen angezeigt + - Prüfung ob CAPTCHA-Response vorhanden + - Validierung der CAPTCHA-Response gegen Google API + - Unterschiedliche Fehlermeldungen für fehlende/ungültige CAPTCHA + - Site Key wird aus Environment-Variable an Template übergeben + +3. **Environment-Konfiguration (.env):** + - RECAPTCHA_SITE_KEY (für Frontend) + - RECAPTCHA_SECRET_KEY (für Backend-Validierung) + - Beide auskommentiert für PoC-Phase + +4. **Dependencies:** + - requests Library zu requirements.txt hinzugefügt + +**Sicherheitsaspekte:** +- CAPTCHA verhindert automatisierte Brute-Force-Angriffe +- Timing-Attack-Schutz bleibt auch bei CAPTCHA-Prüfung aktiv +- Bei Netzwerkfehlern wird CAPTCHA als bestanden gewertet (Verfügbarkeit vor Sicherheit) +- Secret Key wird niemals im Frontend exponiert + +**Verwendung:** +1. Google reCAPTCHA v2 Keys erstellen: https://www.google.com/recaptcha/admin +2. Keys in .env eintragen: + ``` + RECAPTCHA_SITE_KEY=your-site-key + RECAPTCHA_SECRET_KEY=your-secret-key + ``` +3. Container neu starten + +**Status:** +- ✅ CAPTCHA-Frontend bereits vorhanden (login.html) +- ✅ Backend-Validierung vollständig implementiert +- ✅ Fallback für PoC-Betrieb ohne Google-Keys +- ✅ Integration in Rate-Limiting-System +- ⚠️ CAPTCHA-Keys noch nicht konfiguriert (für PoC deaktiviert) + +**Anleitung für Google reCAPTCHA Keys:** + +1. **Registrierung bei Google reCAPTCHA:** + - Gehe zu: https://www.google.com/recaptcha/admin/create + - Melde dich mit Google-Konto an + - Label eingeben: "v2-Docker Admin Panel" + - Typ wählen: "reCAPTCHA v2" → "Ich bin kein Roboter"-Kästchen + - Domains hinzufügen: + ``` + admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com + localhost + ``` + - Nutzungsbedingungen akzeptieren + - Senden klicken + +2. **Keys erhalten:** + - Site Key (öffentlich für Frontend) + - Secret Key (geheim für Backend-Validierung) + +3. **Keys in .env eintragen:** + ```bash + RECAPTCHA_SITE_KEY=6Ld... + RECAPTCHA_SECRET_KEY=6Ld... + ``` + +4. **Container neu starten:** + ```bash + docker-compose down + docker-compose up -d + ``` + +**Kosten:** +- Kostenlos bis 1 Million Anfragen pro Monat +- Danach: $1.00 pro 1000 zusätzliche Anfragen +- Für dieses Projekt reicht die kostenlose Version vollkommen aus + +**Test-Keys für Entwicklung:** +- Site Key: `6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI` +- Secret Key: `6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe` +- ⚠️ Diese Keys nur für lokale Tests verwenden, niemals produktiv! + +**Aktueller Status:** +- Code ist vollständig implementiert und getestet +- CAPTCHA wird nach 2 Fehlversuchen angezeigt +- Ohne konfigurierte Keys wird CAPTCHA-Prüfung übersprungen +- Für Produktion müssen nur die Keys in .env eingetragen werden + +### 2025-06-07 - License Key Generator implementiert +- Automatische Generierung von Lizenzschlüsseln mit definiertem Format + +**Implementiertes Format:** +`AF-YYYYMMFT-XXXX-YYYY-ZZZZ` +- **AF** = Account Factory (feste Produktkennung) +- **YYYY** = Jahr (z.B. 2025) +- **MM** = Monat (z.B. 06) +- **FT** = Lizenztyp (F=Fullversion, T=Testversion) +- **XXXX-YYYY-ZZZZ** = Zufällige alphanumerische Zeichen (ohne verwirrende wie 0/O, 1/I/l) + +**Beispiele:** +- Vollversion: `AF-202506F-A7K9-M3P2-X8R4` +- Testversion: `AF-202512T-B2N5-K8L3-Q9W7` + +**Implementierte Features:** + +1. **Backend-Funktionen (app.py):** + - `generate_license_key()` - Generiert Keys mit kryptografisch sicherem Zufallsgenerator + - `validate_license_key()` - Validiert das Key-Format mit Regex + - Verwendet `secrets` statt `random` für Sicherheit + - Erlaubte Zeichen: ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (ohne verwirrende) + +2. **API-Endpoint:** + - POST `/api/generate-license-key` - JSON API für Key-Generierung + - Prüft auf Duplikate in der Datenbank (max. 10 Versuche) + - Audit-Log-Eintrag bei jeder Generierung + - Login-Required geschützt + +3. **Frontend-Verbesserungen (index.html):** + - Generate-Button neben License Key Input + - Placeholder und Pattern-Attribut für Format-Hinweis + - Auto-Uppercase bei manueller Eingabe + - Visuelles Feedback bei erfolgreicher Generierung + - Format-Hinweis unter dem Eingabefeld + +4. **JavaScript-Features:** + - AJAX-basierte Key-Generierung ohne Seiten-Reload + - Automatische Prüfung bei Lizenztyp-Änderung + - Ladeindikator während der Generierung + - Fehlerbehandlung mit Benutzer-Feedback + - Standard-Datum-Einstellungen (heute + 1 Jahr) + +5. **Validierung:** + - Server-seitige Format-Validierung beim Speichern + - Flash-Message bei ungültigem Format + - Automatische Großschreibung des Keys + - Pattern-Validierung im HTML-Formular + +6. **Weitere Fixes:** + - Form Action von "/" auf "/create" korrigiert + - Flash-Messages mit Bootstrap Toasts implementiert + - GENERATE_KEY Aktion zum Audit-Log hinzugefügt (Farbe: #20c997) + +**Technische Details:** +- Keine vorhersagbaren Muster durch `secrets.choice()` +- Datum im Key zeigt Erstellungszeitpunkt +- Lizenztyp direkt im Key erkennbar +- Kollisionsprüfung gegen Datenbank + +**Status:** +- ✅ Backend-Generierung vollständig implementiert +- ✅ Frontend mit Generate-Button und JavaScript +- ✅ Validierung und Fehlerbehandlung +- ✅ Audit-Log-Integration +- ✅ Form-Action-Bug behoben + +### 2025-06-07 - Batch-Lizenzgenerierung implementiert +- Mehrere Lizenzen auf einmal für einen Kunden erstellen + +**Implementierte Features:** + +1. **Batch-Formular (/batch):** + - Kunde und E-Mail eingeben + - Anzahl der Lizenzen (1-100) + - Lizenztyp (Vollversion/Testversion) + - Gültigkeitszeitraum für alle Lizenzen + - Vorschau-Modal zeigt Key-Format + - Standard-Datum-Einstellungen (heute + 1 Jahr) + +2. **Backend-Verarbeitung:** + - Route `/batch` für GET (Formular) und POST (Generierung) + - Generiert die angegebene Anzahl eindeutiger Keys + - Speichert alle in einer Transaktion + - Kunde wird automatisch angelegt (falls nicht vorhanden) + - ON CONFLICT für existierende Kunden + - Audit-Log-Eintrag mit CREATE_BATCH Aktion + +3. **Ergebnis-Seite:** + - Zeigt alle generierten Lizenzen in Tabellenform + - Kundeninformationen und Gültigkeitszeitraum + - Einzelne Keys können kopiert werden (📋 Button) + - Alle Keys auf einmal kopieren + - Druckfunktion für physische Ausgabe + - Link zur Lizenzübersicht mit Kundenfilter + +4. **Export-Funktionalität:** + - Route `/batch/export` für CSV-Download + - Speichert Batch-Daten in Session für Export + - CSV mit UTF-8 BOM für Excel-Kompatibilität + - Enthält Kundeninfo, Generierungsdatum und alle Keys + - Format: Nr;Lizenzschlüssel;Typ + - Dateiname: batch_licenses_KUNDE_TIMESTAMP.csv + +5. **Integration:** + - Batch-Button in Navigation (Dashboard, Einzellizenz-Seite) + - CREATE_BATCH Aktion im Audit-Log (Farbe: #6610f2) + - Session-basierte Export-Daten + - Flash-Messages für Feedback + +**Sicherheit:** +- Limit von 100 Lizenzen pro Batch +- Login-Required für alle Routen +- Transaktionale Datenbank-Operationen +- Validierung der Eingaben + +**Beispiel-Workflow:** +1. Admin geht zu `/batch` +2. Gibt Kunde "Firma GmbH", Anzahl "25", Typ "Vollversion" ein +3. System generiert 25 eindeutige Keys +4. Ergebnis-Seite zeigt alle Keys +5. Admin kann CSV exportieren oder Keys kopieren +6. Kunde erhält die Lizenzen + +**Status:** +- ✅ Batch-Formular vollständig implementiert +- ✅ Backend-Generierung mit Transaktionen +- ✅ Export als CSV +- ✅ Copy-to-Clipboard Funktionalität +- ✅ Audit-Log-Integration +- ✅ Navigation aktualisiert + +## 2025-06-06: Implementierung Searchable Dropdown für Kundenauswahl + +**Problem:** +- Bei der Lizenzerstellung wurde immer ein neuer Kunde angelegt +- Keine Möglichkeit, Lizenzen für bestehende Kunden zu erstellen +- Bei vielen Kunden wäre ein normales Dropdown unübersichtlich + +**Lösung:** +1. **Select2 Library** für searchable Dropdown integriert +2. **API-Endpoint `/api/customers`** für die Kundensuche erstellt +3. **Frontend angepasst:** + - Searchable Dropdown mit Live-Suche + - Option "Neuer Kunde" im Dropdown + - Eingabefelder erscheinen nur bei "Neuer Kunde" +4. **Backend-Logik verbessert:** + - Prüfung ob neuer oder bestehender Kunde + - E-Mail-Duplikatsprüfung vor Kundenerstellung + - Separate Audit-Logs für Kunde und Lizenz +5. **Datenbank:** + - UNIQUE Constraint auf E-Mail-Spalte hinzugefügt + +**Änderungen:** +- `app.py`: Neuer API-Endpoint `/api/customers`, angepasste Routes `/create` und `/batch` +- `base.html`: Select2 CSS und JS eingebunden +- `index.html`: Kundenauswahl mit Select2 implementiert +- `batch_form.html`: Kundenauswahl mit Select2 implementiert +- `init.sql`: UNIQUE Constraint für E-Mail + +**Status:** +- ✅ API-Endpoint funktioniert mit Pagination +- ✅ Select2 Dropdown mit Suchfunktion +- ✅ Neue/bestehende Kunden können ausgewählt werden +- ✅ E-Mail-Duplikate werden verhindert +- ✅ Sowohl Einzellizenz als auch Batch unterstützt + +## 2025-06-06: Automatische Ablaufdatum-Berechnung + +**Problem:** +- Manuelles Eingeben von Start- und Enddatum war umständlich +- Fehleranfällig bei der Datumseingabe +- Nicht intuitiv für Standard-Laufzeiten + +**Lösung:** +1. **Frontend-Änderungen:** + - Startdatum + Laufzeit (Zahl) + Einheit (Tage/Monate/Jahre) + - Ablaufdatum wird automatisch berechnet und angezeigt (read-only) + - Standard: 1 Jahr Laufzeit voreingestellt +2. **Backend-Validierung:** + - Server-seitige Berechnung zur Sicherheit + - Verwendung von `python-dateutil` für korrekte Monats-/Jahresberechnungen +3. **Benutzerfreundlichkeit:** + - Sofortige Neuberechnung bei Änderungen + - Visuelle Hervorhebung des berechneten Datums + +**Änderungen:** +- `index.html`: Laufzeit-Eingabe statt Ablaufdatum +- `batch_form.html`: Laufzeit-Eingabe statt Ablaufdatum +- `app.py`: Datum-Berechnung in `/create` und `/batch` Routes +- `requirements.txt`: `python-dateutil` hinzugefügt + +**Status:** +- ✅ Automatische Berechnung funktioniert +- ✅ Frontend zeigt berechnetes Datum sofort an +- ✅ Backend validiert die Berechnung +- ✅ Standardwert (1 Jahr) voreingestellt + +## 2025-06-06: Bugfix - created_at für licenses Tabelle + +**Problem:** +- Batch-Generierung schlug fehl mit "Fehler bei der Batch-Generierung!" +- INSERT Statement versuchte `created_at` zu setzen, aber Spalte existierte nicht +- Inkonsistenz: Einzellizenzen hatten kein created_at, Batch-Lizenzen versuchten es zu setzen + +**Lösung:** +1. **Datenbank-Schema erweitert:** + - `created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` zur licenses Tabelle hinzugefügt + - Migration für bestehende Datenbanken implementiert + - Konsistent mit customers Tabelle +2. **Code bereinigt:** + - Explizites `created_at` aus Batch-INSERT entfernt + - Datenbank setzt nun automatisch den Zeitstempel bei ALLEN Lizenzen + +**Änderungen:** +- `init.sql`: created_at Spalte zur licenses Tabelle mit DEFAULT-Wert +- `init.sql`: Migration für bestehende Datenbanken +- `app.py`: Entfernt explizites created_at aus batch_licenses() + +**Status:** +- ✅ Alle Lizenzen haben nun automatisch einen Erstellungszeitstempel +- ✅ Batch-Generierung funktioniert wieder +- ✅ Konsistente Zeitstempel für Audit-Zwecke + +## 2025-06-06: Status "Deaktiviert" für manuell abgeschaltete Lizenzen + +**Problem:** +- Dashboard zeigte nur "aktiv" und "abgelaufen" als Status +- Manuell deaktivierte Lizenzen (is_active = FALSE) wurden nicht korrekt angezeigt +- Filter für "inactive" existierte, aber Status wurde nicht richtig berechnet + +**Lösung:** +1. **Status-Berechnung erweitert:** + - CASE-Statement prüft zuerst `is_active = FALSE` + - Status "deaktiviert" wird vor anderen Status geprüft + - Reihenfolge: deaktiviert → abgelaufen → läuft bald ab → aktiv +2. **Dashboard-Statistik erweitert:** + - Neue Zählung für deaktivierte Lizenzen + - Variable `inactive_licenses` im stats Dictionary + +**Änderungen:** +- `app.py`: Dashboard - Status-Berechnung für letzte 5 Lizenzen +- `app.py`: Lizenzübersicht - Status-Berechnung in der Hauptabfrage +- `app.py`: Export - Status-Berechnung für CSV/Excel Export +- `app.py`: Dashboard - Neue Statistik für deaktivierte Lizenzen + +**Status:** +- ✅ "Deaktiviert" wird korrekt als Status angezeigt +- ✅ Dashboard zeigt Anzahl deaktivierter Lizenzen +- ✅ Export enthält korrekten Status +- ✅ Konsistente Status-Anzeige überall + +## 2025-06-08: SSL-Sicherheit verbessert - Chrome Warnung behoben + +**Problem:** +- Chrome zeigte Warnung "Die Verbindung zu dieser Website ist nicht sicher" +- Nginx erlaubte schwache Cipher Suites (WEAK) ohne Perfect Forward Secrecy +- Veraltete SSL-Konfiguration mit `ssl_ciphers HIGH:!aNULL:!MD5;` + +**Lösung:** +1. **Moderne Cipher Suite Konfiguration:** + - Nur sichere ECDHE und DHE Cipher Suites + - Entfernung aller RSA-only Cipher Suites + - Perfect Forward Secrecy für alle Verbindungen +2. **SSL-Optimierungen:** + - Session Cache aktiviert (1 Tag Timeout) + - OCSP Stapling für bessere Performance + - DH Parameters (2048 bit) für zusätzliche Sicherheit +3. **Resolver-Konfiguration:** + - Google DNS Server für OCSP Stapling + +**Änderungen:** +- `v2_nginx/nginx.conf`: Komplett überarbeitete SSL-Konfiguration +- `v2_nginx/ssl/dhparam.pem`: Neue 2048-bit DH Parameters generiert +- `v2_nginx/Dockerfile`: COPY Befehl für dhparam.pem hinzugefügt + +**Status:** +- ✅ Nur noch sichere Cipher Suites aktiv +- ✅ Perfect Forward Secrecy gewährleistet +- ✅ OCSP Stapling aktiviert +- ✅ Chrome Sicherheitswarnung behoben + +**Hinweis:** Nach dem Rebuild des nginx Containers wird die Verbindung als sicher angezeigt. + +## 2025-06-08: CAPTCHA-Login-Bug behoben + +**Problem:** +- Nach 2 fehlgeschlagenen Login-Versuchen wurde CAPTCHA angezeigt +- Da keine CAPTCHA-Keys konfiguriert waren (für PoC), konnte man sich nicht mehr einloggen +- Selbst mit korrektem Passwort war Login blockiert +- Fehlermeldung "CAPTCHA ERFORDERLICH!" erschien immer + +**Lösung:** +1. **CAPTCHA-Prüfung nur wenn Keys vorhanden:** + - `recaptcha_site_key` wird vor CAPTCHA-Prüfung geprüft + - Wenn keine Keys konfiguriert → kein CAPTCHA-Check + - CAPTCHA wird nur angezeigt wenn Keys existieren +2. **Template-Anpassungen:** + - login.html zeigt CAPTCHA nur wenn `recaptcha_site_key` vorhanden + - Kein Test-Key mehr als Fallback +3. **Konsistente Logik:** + - show_captcha prüft jetzt auch ob Keys vorhanden sind + - Bei GET und POST Requests gleiche Logik + +**Änderungen:** +- `v2_adminpanel/app.py`: CAPTCHA-Check nur wenn `RECAPTCHA_SITE_KEY` existiert +- `v2_adminpanel/templates/login.html`: CAPTCHA nur anzeigen wenn Keys vorhanden + +**Status:** +- ✅ Login funktioniert wieder nach 2+ Fehlversuchen +- ✅ CAPTCHA wird nur angezeigt wenn Keys konfiguriert sind +- ✅ Für PoC-Phase ohne CAPTCHA nutzbar +- ✅ Produktiv-ready wenn CAPTCHA-Keys eingetragen werden + +### 2025-06-08: Zeitzone auf Europe/Berlin umgestellt + +**Problem:** +- Alle Zeitstempel wurden in UTC gespeichert und angezeigt +- Backup-Dateinamen zeigten UTC-Zeit statt deutsche Zeit +- Verwirrung bei Zeitangaben im Admin Panel und Logs + +**Lösung:** +1. **Docker Container Zeitzone konfiguriert:** + - Alle Dockerfiles mit `TZ=Europe/Berlin` und tzdata Installation + - PostgreSQL mit `PGTZ=Europe/Berlin` für Datenbank-Zeitzone + - Explizite Zeitzone-Dateien in /etc/localtime und /etc/timezone + +2. **Python Code angepasst:** + - Import von `zoneinfo.ZoneInfo` für Zeitzonenunterstützung + - Alle `datetime.now()` Aufrufe mit `ZoneInfo("Europe/Berlin")` + - `.replace(tzinfo=None)` für Kompatibilität mit timezone-unaware Timestamps + +3. **PostgreSQL Konfiguration:** + - `SET timezone = 'Europe/Berlin';` in init.sql + - Umgebungsvariablen TZ und PGTZ in docker-compose.yaml + +4. **docker-compose.yaml erweitert:** + - `TZ: Europe/Berlin` für alle Services + +**Geänderte Dateien:** +- `v2_adminpanel/Dockerfile`: Zeitzone und tzdata hinzugefügt +- `v2_postgres/Dockerfile`: Zeitzone und tzdata hinzugefügt +- `v2_nginx/Dockerfile`: Zeitzone und tzdata hinzugefügt +- `v2_lizenzserver/Dockerfile`: Zeitzone und tzdata hinzugefügt +- `v2_adminpanel/app.py`: 14 datetime.now() Aufrufe mit Zeitzone versehen +- `v2_adminpanel/init.sql`: PostgreSQL Zeitzone gesetzt +- `v2/docker-compose.yaml`: TZ Environment-Variable für alle Services + +**Ergebnis:** +- ✅ Alle neuen Zeitstempel werden in deutscher Zeit (Europe/Berlin) gespeichert +- ✅ Backup-Dateinamen zeigen korrekte deutsche Zeit +- ✅ Admin Panel zeigt alle Zeiten in deutscher Zeitzone +- ✅ Automatische Anpassung bei Sommer-/Winterzeit +- ✅ Konsistente Zeitangaben über alle Komponenten + +**Hinweis:** Nach diesen Änderungen müssen die Docker Container neu gebaut werden: +```bash +docker-compose down +docker-compose build +docker-compose up -d +``` + +### 2025-06-08: Zeitzone-Fix - PostgreSQL Timestamps + +**Problem nach erster Implementierung:** +- Trotz Zeitzoneneinstellung wurden Zeiten immer noch in UTC angezeigt +- Grund: PostgreSQL Tabellen verwendeten `TIMESTAMP WITHOUT TIME ZONE` + +**Zusätzliche Lösung:** +1. **Datenbankschema angepasst:** + - Alle `TIMESTAMP` Spalten auf `TIMESTAMP WITH TIME ZONE` geändert + - Betrifft: created_at, timestamp, started_at, ended_at, last_heartbeat, etc. + - Migration für bestehende Datenbanken berücksichtigt + +2. **SQL-Abfragen vereinfacht:** + - `AT TIME ZONE 'Europe/Berlin'` nicht mehr nötig + - PostgreSQL handhabt Zeitzonenkonvertierung automatisch + +**Geänderte Datei:** +- `v2_adminpanel/init.sql`: Alle TIMESTAMP Felder mit WITH TIME ZONE + +**Wichtig:** Bei bestehenden Installationen muss die Datenbank neu initialisiert oder manuell migriert werden: +```sql +ALTER TABLE customers ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; +ALTER TABLE licenses ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; +ALTER TABLE sessions ALTER COLUMN started_at TYPE TIMESTAMP WITH TIME ZONE; +ALTER TABLE sessions ALTER COLUMN last_heartbeat TYPE TIMESTAMP WITH TIME ZONE; +ALTER TABLE sessions ALTER COLUMN ended_at TYPE TIMESTAMP WITH TIME ZONE; +ALTER TABLE audit_log ALTER COLUMN timestamp TYPE TIMESTAMP WITH TIME ZONE; +ALTER TABLE backup_history ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; +ALTER TABLE login_attempts ALTER COLUMN first_attempt TYPE TIMESTAMP WITH TIME ZONE; +ALTER TABLE login_attempts ALTER COLUMN last_attempt TYPE TIMESTAMP WITH TIME ZONE; +ALTER TABLE login_attempts ALTER COLUMN blocked_until TYPE TIMESTAMP WITH TIME ZONE; +``` + +### 2025-06-08: UI/UX Überarbeitung - Phase 1 (Navigation) + +**Problem:** +- Inkonsistente Navigation zwischen verschiedenen Seiten +- Zu viele Navigationspunkte im Dashboard +- Verwirrende Benutzerführung + +**Lösung:** +1. **Dashboard vereinfacht:** + - Nur noch 3 Buttons: Neue Lizenz, Batch-Lizenzen, Log + - Statistik-Karten wurden klickbar gemacht (verlinken zu jeweiligen Seiten) + - "Audit" wurde zu "Log" umbenannt + +2. **Navigation konsistent gemacht:** + - Navbar-Brand "AccountForger - Admin Panel" ist jetzt klickbar und führt zum Dashboard + - Keine Log-Links mehr in Unterseiten + - Konsistente "Dashboard" Buttons in allen Unterseiten + +**Geänderte Dateien:** +- `v2_adminpanel/templates/base.html`: Navbar-Brand klickbar gemacht +- `v2_adminpanel/templates/dashboard.html`: Navigation reduziert, Karten klickbar +- `v2_adminpanel/templates/*.html`: Konsistente Dashboard-Links + +### 2025-06-08: UI/UX Überarbeitung - Phase 2 (Visuelle Verbesserungen) + +**Implementierte Verbesserungen:** +1. **Größere Icons in Statistik-Karten:** + - Icon-Größe auf 3rem erhöht + - Bessere visuelle Hierarchie + +2. **Donut-Chart für Lizenzen:** + - Chart.js Integration für Lizenzstatistik + - Zeigt Verhältnis Aktiv/Abgelaufen + - UPDATE: Später wieder entfernt auf Benutzerwunsch + +3. **Pulse-Effekt für aktive Sessions:** + - CSS-Animation für aktive Sessions + - Visueller Indikator für Live-Aktivität + +4. **Progress-Bar für Backup-Status:** + - Zeigt visuell den Erfolg des letzten Backups + - Inkl. Dateigröße und Dauer + +5. **Konsistente Farbcodierung:** + - CSS-Variablen für Statusfarben + - Globale Klassen für konsistente Darstellung + +**Geänderte Dateien:** +- `v2_adminpanel/templates/base.html`: Globale CSS-Variablen und Statusklassen +- `v2_adminpanel/templates/dashboard.html`: Visuelle Verbesserungen implementiert + +### 2025-06-08: UI/UX Überarbeitung - Phase 3 (Tabellen-Optimierungen) + +**Problem:** +- Tabellen waren schwer zu navigieren bei vielen Einträgen +- Keine Möglichkeit für Bulk-Operationen +- Umständliches Kopieren von Lizenzschlüsseln + +**Lösung:** +1. **Sticky Headers:** + - Tabellenköpfe bleiben beim Scrollen sichtbar + - CSS-Klasse `.table-sticky` mit `position: sticky` + +2. **Inline-Actions:** + - Copy-Button direkt neben Lizenzschlüsseln + - Toggle-Switches für Aktiv/Inaktiv-Status + - Visuelles Feedback bei Aktionen + +3. **Bulk-Actions:** + - Checkboxen für Mehrfachauswahl + - "Select All" Funktionalität + - Bulk-Actions Bar mit Aktivieren/Deaktivieren/Löschen + - JavaScript für dynamische Anzeige + +4. **API-Endpoints hinzugefügt:** + - `/api/license//toggle` - Toggle einzelner Lizenzstatus + - `/api/licenses/bulk-activate` - Mehrere Lizenzen aktivieren + - `/api/licenses/bulk-deactivate` - Mehrere Lizenzen deaktivieren + - `/api/licenses/bulk-delete` - Mehrere Lizenzen löschen + +5. **Beispieldaten eingefügt:** + - 15 Testkunden + - 18 Lizenzen (verschiedene Status) + - Sessions, Audit-Logs, Login-Attempts + - Backup-Historie + +**Geänderte Dateien:** +- `v2_adminpanel/templates/base.html`: CSS für Sticky-Tables und Bulk-Actions +- `v2_adminpanel/templates/licenses.html`: Komplette Tabellen-Überarbeitung +- `v2_adminpanel/app.py`: 4 neue API-Endpoints für Toggle und Bulk-Operationen +- `v2_adminpanel/sample_data.sql`: Umfangreiche Testdaten erstellt + +**Bugfix:** +- API-Endpoints versuchten `updated_at` zu setzen, obwohl die Spalte nicht existiert +- Entfernt aus allen 3 betroffenen Endpoints + +**Status:** +- ✅ Sticky Headers funktionieren +- ✅ Copy-Buttons mit Clipboard-API +- ✅ Toggle-Switches ändern Lizenzstatus +- ✅ Bulk-Operationen vollständig implementiert +- ✅ Testdaten erfolgreich eingefügt + +### 2025-06-08: UI/UX Überarbeitung - Phase 4 (Sortierbare Tabellen) + +**Problem:** +- Keine Möglichkeit, Tabellen nach verschiedenen Spalten zu sortieren +- Besonders bei großen Datenmengen schwer zu navigieren + +**Lösung - Hybrid-Ansatz:** +1. **Client-seitige Sortierung für kleine Tabellen:** + - Dashboard (3 kleine Übersichtstabellen) + - Blocked IPs (typischerweise wenige Einträge) + - Backups (begrenzte Anzahl) + - JavaScript-basierte Sortierung ohne Reload + +2. **Server-seitige Sortierung für große Tabellen:** + - Licenses (potenziell tausende Einträge) + - Customers (viele Kunden möglich) + - Audit Log (wächst kontinuierlich) + - Sessions (viele aktive/beendete Sessions) + - URL-Parameter für Sortierung mit SQL ORDER BY + +**Implementierung:** +1. **Client-seitige Sortierung:** + - Generische JavaScript-Funktion in base.html + - CSS-Klasse `.sortable-table` für betroffene Tabellen + - Sortier-Indikatoren (↑↓↕) bei Hover/Active + - Unterstützung für Text, Zahlen und deutsche Datumsformate + +2. **Server-seitige Sortierung:** + - Query-Parameter `sort` und `order` in Routes + - Whitelist für erlaubte Sortierfelder (SQL-Injection-Schutz) + - Makro-Funktionen für sortierbare Header + - Sortier-Parameter in Pagination-Links erhalten + +**Geänderte Dateien:** +- `v2_adminpanel/templates/base.html`: CSS und JavaScript für Sortierung +- `v2_adminpanel/templates/dashboard.html`: Client-seitige Sortierung +- `v2_adminpanel/templates/blocked_ips.html`: Client-seitige Sortierung +- `v2_adminpanel/templates/backups.html`: Client-seitige Sortierung +- `v2_adminpanel/templates/licenses.html`: Server-seitige Sortierung +- `v2_adminpanel/templates/customers.html`: Server-seitige Sortierung +- `v2_adminpanel/templates/audit_log.html`: Server-seitige Sortierung +- `v2_adminpanel/templates/sessions.html`: Server-seitige Sortierung (2 Tabellen) +- `v2_adminpanel/app.py`: 4 Routes erweitert für Sortierung + +**Besonderheiten:** +- Sessions-Seite hat zwei unabhängige Tabellen mit eigenen Sortierparametern +- Intelligente Datentyp-Erkennung (numeric, date) für korrekte Sortierung +- Visuelle Sortier-Indikatoren zeigen aktuelle Sortierung +- Alle anderen Filter und Suchparameter bleiben bei Sortierung erhalten + +**Status:** +- ✅ Client-seitige Sortierung für kleine Tabellen +- ✅ Server-seitige Sortierung für große Tabellen +- ✅ Sortier-Indikatoren und visuelle Rückmeldung +- ✅ SQL-Injection-Schutz durch Whitelisting +- ✅ Vollständige Integration mit bestehenden Features + +### 2025-06-08: Bugfix - Sortierlogik korrigiert + +**Problem:** +- Sortierung funktionierte nicht korrekt +- Beim Klick auf Spaltenköpfe wurde immer absteigend sortiert +- Toggle zwischen ASC/DESC funktionierte nicht + +**Ursachen:** +1. **Falsche Bedingungslogik**: Die ursprüngliche Implementierung verwendete eine fehlerhafte Ternär-Bedingung +2. **Berechnete Felder**: Das 'status' Feld in der Lizenztabelle konnte nicht direkt sortiert werden + +**Lösung:** +1. **Sortierlogik korrigiert:** + - Bei neuer Spalte: Immer aufsteigend (ASC) beginnen + - Bei gleicher Spalte: Toggle zwischen ASC und DESC + - Implementiert durch bedingte Links in den Makros + +2. **Spezialbehandlung für berechnete Felder:** + - Status-Feld verwendet CASE-Statement in ORDER BY + - Wiederholt die gleiche Logik wie im SELECT + +**Geänderte Dateien:** +- `v2_adminpanel/templates/licenses.html`: Sortierlogik korrigiert +- `v2_adminpanel/templates/customers.html`: Sortierlogik korrigiert +- `v2_adminpanel/templates/audit_log.html`: Sortierlogik korrigiert +- `v2_adminpanel/templates/sessions.html`: Sortierlogik für beide Tabellen korrigiert +- `v2_adminpanel/app.py`: Spezialbehandlung für Status-Feld in licenses Route + +**Verhalten nach Fix:** +- ✅ Erster Klick auf Spalte: Aufsteigend sortieren +- ✅ Zweiter Klick: Absteigend sortieren +- ✅ Weitere Klicks: Toggle zwischen ASC/DESC +- ✅ Sortierung funktioniert korrekt mit Pagination und Filtern + +### 2025-06-09: Port 8443 geschlossen - API nur noch über Nginx + +**Problem:** +- Doppelte Verfügbarkeit des License Servers (Port 8443 + Nginx) machte keinen Sinn +- Inkonsistente Sicherheitskonfiguration (Nginx hatte Security Headers, Port 8443 nicht) +- Doppelte SSL-Konfiguration nötig +- Verwirrung welcher Zugangsweg genutzt werden soll + +**Lösung:** +- Port-Mapping für License Server in docker-compose.yaml entfernt +- API nur noch über Nginx erreichbar: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com +- Interne Kommunikation zwischen Nginx und License Server bleibt bestehen + +**Vorteile:** +- ✅ Einheitliche Sicherheitskonfiguration (Security Headers, HSTS) +- ✅ Zentrale SSL-Verwaltung nur in Nginx +- ✅ Möglichkeit für Rate Limiting und zentrales Logging +- ✅ Keine zusätzlichen offenen Ports (nur 80/443) +- ✅ Professionellere API-URL ohne Port-Angabe + +**Geänderte Dateien:** +- `v2/docker-compose.yaml`: Port-Mapping "8443:8443" entfernt + +**Hinweis für Client-Software:** +- API-Endpunkte sind weiterhin unter https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com erreichbar +- Keine Änderung der API-URLs nötig, nur Port 8443 ist nicht mehr direkt zugänglich + +**Status:** +- ✅ Port 8443 geschlossen +- ✅ API nur noch über Nginx Reverse Proxy erreichbar +- ✅ Sicherheit erhöht durch zentrale Verwaltung + +### 2025-06-09: Live-Filtering implementiert + +**Problem:** +- Benutzer mussten immer auf "Filter anwenden" klicken +- Umständliche Bedienung, besonders bei mehreren Filterkriterien +- Nicht zeitgemäße User Experience + +**Lösung:** +- JavaScript Event-Listener für automatisches Filtern +- Text-Eingaben: 300ms Debouncing (verzögerte Suche nach Tipp-Pause) +- Dropdowns: Sofortiges Filtern bei Änderung +- "Filter anwenden" Button entfernt, nur "Zurücksetzen" bleibt + +**Implementierte Live-Filter:** +1. **Lizenzübersicht** (licenses.html): + - Suchfeld mit Debouncing + - Typ-Dropdown (Vollversion/Testversion) + - Status-Dropdown (Aktiv/Ablaufend/Abgelaufen/Deaktiviert) + +2. **Kundenübersicht** (customers.html): + - Suchfeld mit Debouncing + - "Suchen" Button entfernt + +3. **Audit-Log** (audit_log.html): + - Benutzer-Textfeld mit Debouncing + - Aktion-Dropdown + - Entität-Dropdown + +**Technische Details:** +- `addEventListener('input')` für Textfelder +- `addEventListener('change')` für Select-Elemente +- `setTimeout()` mit 300ms für Debouncing +- Automatisches `form.submit()` bei Änderungen + +**Vorteile:** +- ✅ Schnellere und intuitivere Bedienung +- ✅ Weniger Klicks erforderlich +- ✅ Moderne User Experience +- ✅ Besonders hilfreich bei komplexen Filterkriterien + +**Status:** +- ✅ Live-Filtering auf allen Hauptseiten implementiert +- ✅ Debouncing verhindert zu viele Server-Requests +- ✅ Zurücksetzen-Button bleibt für schnelles Löschen aller Filter + +### 2025-06-09: Resource Pool System implementiert (Phase 1 & 2) + +**Ziel:** +Ein Pool-System für Domains, IPv4-Adressen und Telefonnummern, wobei bei jeder Lizenzerstellung 1-10 Ressourcen pro Typ zugewiesen werden. Ressourcen haben 3 Status: available, allocated, quarantine. + +**Phase 1 - Datenbank-Schema (✅ Abgeschlossen):** +1. **Neue Tabellen erstellt:** + - `resource_pools` - Haupttabelle für alle Ressourcen + - `resource_history` - Vollständige Historie aller Aktionen + - `resource_metrics` - Performance-Tracking und ROI-Berechnung + - `license_resources` - Zuordnung zwischen Lizenzen und Ressourcen + +2. **Erweiterte licenses Tabelle:** + - `domain_count`, `ipv4_count`, `phone_count` Spalten hinzugefügt + - Constraints: 0-10 pro Resource-Typ + +3. **Indizes für Performance:** + - Status, Type, Allocated License, Quarantine Date + +**Phase 2 - Backend-Implementierung (✅ Abgeschlossen):** +1. **Resource Management Routes:** + - `/resources` - Hauptübersicht mit Statistiken + - `/resources/add` - Bulk-Import von Ressourcen + - `/resources/quarantine/` - Ressourcen sperren + - `/resources/release` - Quarantäne aufheben + - `/resources/history/` - Komplette Historie + - `/resources/metrics` - Performance Dashboard + - `/resources/report` - Report-Generator + +2. **API-Endpunkte:** + - `/api/resources/allocate` - Ressourcen-Zuweisung + - `/api/resources/check-availability` - Verfügbarkeit prüfen + +3. **Integration in Lizenzerstellung:** + - `create_license()` erweitert um Resource-Allocation + - `batch_licenses()` mit Ressourcen-Prüfung für gesamten Batch + - Transaktionale Sicherheit bei Zuweisung + +4. **Dashboard-Integration:** + - Resource-Statistiken in Dashboard eingebaut + - Warning-Level basierend auf Verfügbarkeit + +5. **Navigation erweitert:** + - Resources-Link in Navbar hinzugefügt + +**Was noch zu tun ist:** + +### Phase 3 - UI-Komponenten (🔄 Ausstehend): +1. **Templates erstellen:** + - `resources.html` - Hauptübersicht mit Drag&Drop + - `add_resources.html` - Formular für Bulk-Import + - `resource_history.html` - Historie-Anzeige + - `resource_metrics.html` - Performance Dashboard + +2. **Formulare erweitern:** + - `index.html` - Resource-Dropdowns hinzufügen + - `batch_form.html` - Resource-Dropdowns hinzufügen + +3. **Dashboard-Widget:** + - Resource Pool Statistik mit Ampelsystem + - Warnung bei niedrigem Bestand + +### Phase 4 - Erweiterte Features (🔄 Ausstehend): +1. **Quarantäne-Workflow:** + - Gründe: abuse, defect, maintenance, blacklisted, expired + - Automatische Tests vor Freigabe + - Genehmigungsprozess + +2. **Performance-Metrics:** + - Täglicher Cronjob für Metriken + - ROI-Berechnung + - Issue-Tracking + +3. **Report-Generator:** + - Auslastungsreport + - Performance-Report + - Compliance-Report + +### Phase 5 - Backup erweitern (🔄 Ausstehend): +- Neue Tabellen in Backup einbeziehen: + - resource_pools + - resource_history + - resource_metrics + - license_resources + +### Phase 6 - Testing & Migration (🔄 Ausstehend): +1. **Test-Daten generieren:** + - 500 Test-Domains + - 200 Test-IPs + - 100 Test-Telefonnummern + +2. **Migrations-Script:** + - Bestehende Lizenzen auf default resource_count setzen + +### Phase 7 - Dokumentation (🔄 Ausstehend): +- API-Dokumentation für License Server +- Admin-Handbuch für Resource Management + +**Technische Details:** +- 3-Status-System: available/allocated/quarantine +- Transaktionale Ressourcen-Zuweisung mit FOR UPDATE Lock +- Vollständige Historie mit IP-Tracking +- Drag&Drop UI für Resource-Management geplant +- Automatische Warnung bei < 50 verfügbaren Ressourcen + +**Status:** +- ✅ Datenbank-Schema komplett +- ✅ Backend-Routen implementiert +- ✅ Integration in Lizenzerstellung +- ❌ UI-Templates fehlen noch +- ❌ Erweiterte Features ausstehend +- ❌ Testing und Migration offen + +### 2025-06-09: Resource Pool System UI-Implementierung (Phase 3 & 4) + +**Phase 3 - UI-Komponenten (✅ Abgeschlossen):** + +1. **Neue Templates erstellt:** + - `resources.html` - Hauptübersicht mit Statistiken, Filter, Live-Suche, Pagination + - `add_resources.html` - Bulk-Import Formular mit Validierung + - `resource_history.html` - Timeline-Ansicht der Historie mit Details + - `resource_metrics.html` - Performance Dashboard mit Charts + - `resource_report.html` - Report-Generator UI + +2. **Erweiterte Formulare:** + - `index.html` - Resource-Count Dropdowns (0-10) mit Live-Verfügbarkeitsprüfung + - `batch_form.html` - Resource-Count mit Batch-Berechnung (zeigt Gesamtbedarf) + +3. **Dashboard-Widget:** + - Resource Pool Statistik mit Ampelsystem implementiert + - Zeigt verfügbare/zugeteilte/quarantäne Ressourcen + - Warnung bei niedrigem Bestand (<50) + - Fortschrittsbalken für visuelle Darstellung + +4. **Backend-Anpassungen:** + - `resource_history` Route korrigiert für Object-Style Template-Zugriff + - `resources_metrics` Route vollständig implementiert mit Charts-Daten + - `resources_report` Route erweitert für Template-Anzeige und Downloads + - Dashboard erweitert um Resource-Statistiken + +**Phase 4 - Erweiterte Features (✅ Teilweise):** +1. **Report-Generator:** + - Template für Report-Auswahl erstellt + - 4 Report-Typen: Usage, Performance, Compliance, Inventory + - Export als Excel, CSV oder PDF-Vorschau + - Zeitraum-Auswahl mit Validierung + +**Technische Details der Implementierung:** +- Live-Filtering ohne Reload durch JavaScript +- AJAX-basierte Verfügbarkeitsprüfung +- Bootstrap 5 für konsistentes Design +- Chart.js für Metriken-Visualisierung +- Responsives Design für alle Templates +- Copy-to-Clipboard für Resource-Werte +- Modal-Dialoge für Quarantäne-Aktionen + +**Was noch fehlt:** + +### Phase 5 - Backup erweitern (🔄 Ausstehend): +- Resource-Tabellen in pg_dump einbeziehen: + - resource_pools + - resource_history + - resource_metrics + - license_resources + +### Phase 6 - Testing & Migration (🔄 Ausstehend): +1. **Test-Daten generieren:** + - Script für 500 Test-Domains + - 200 Test-IPv4-Adressen + - 100 Test-Telefonnummern + - Realistische Verteilung über Status + +2. **Migrations-Script:** + - Bestehende Lizenzen auf Default resource_count setzen + - UPDATE licenses SET domain_count=1, ipv4_count=1, phone_count=1 WHERE ... + +### Phase 7 - Dokumentation (🔄 Ausstehend): +- API-Dokumentation für Resource-Endpunkte +- Admin-Handbuch für Resource Management +- Troubleshooting-Guide + +**Offene Punkte für Produktion:** +1. Drag&Drop für Resource-Verwaltung (Nice-to-have) +2. Automatische Quarantäne-Aufhebung nach Zeitablauf +3. E-Mail-Benachrichtigungen bei niedrigem Bestand +4. API für externe Resource-Prüfung +5. Bulk-Delete für Ressourcen +6. Resource-Import aus CSV/Excel + +### 2025-06-09: Resource Pool System finalisiert + +**Problem:** +- Resource Pool System war nur teilweise implementiert +- UI-Templates waren vorhanden, aber nicht dokumentiert +- Test-Daten und Migration fehlten +- Backup-Integration unklar + +**Analyse und Lösung:** +1. **Status-Überprüfung durchgeführt:** + - Alle 5 UI-Templates existierten bereits (resources.html, add_resources.html, etc.) + - Resource-Dropdowns waren bereits in index.html und batch_form.html integriert + - Dashboard-Widget war bereits implementiert + - Backup-System inkludiert bereits alle Tabellen (pg_dump ohne -t Parameter) + +2. **Fehlende Komponenten erstellt:** + - Test-Daten Script: `test_data_resources.sql` + - 500 Test-Domains (400 verfügbar, 50 zugeteilt, 50 in Quarantäne) + - 200 Test-IPv4-Adressen (150 verfügbar, 30 zugeteilt, 20 in Quarantäne) + - 100 Test-Telefonnummern (70 verfügbar, 20 zugeteilt, 10 in Quarantäne) + - Resource History und Metrics für realistische Daten + + - Migration Script: `migrate_existing_licenses.sql` + - Setzt Default Resource Counts (Vollversion: 2, Testversion: 1, Inaktiv: 0) + - Weist automatisch verfügbare Ressourcen zu + - Erstellt Audit-Log Einträge + - Gibt detaillierten Migrationsbericht aus + +**Neue Dateien:** +- `v2_adminpanel/test_data_resources.sql` - Testdaten für Resource Pool +- `v2_adminpanel/migrate_existing_licenses.sql` - Migration für bestehende Lizenzen + +**Status:** +- ✅ Resource Pool System vollständig implementiert und dokumentiert +- ✅ Alle UI-Komponenten vorhanden und funktionsfähig +- ✅ Integration in Lizenz-Formulare abgeschlossen +- ✅ Dashboard-Widget zeigt Resource-Statistiken +- ✅ Backup-System inkludiert Resource-Tabellen +- ✅ Test-Daten und Migration bereitgestellt + +**Nächste Schritte:** +1. Test-Daten einspielen: `psql -U adminuser -d meinedatenbank -f test_data_resources.sql` +2. Migration ausführen: `psql -U adminuser -d meinedatenbank -f migrate_existing_licenses.sql` +3. License Server API implementieren (Hauptaufgabe) + +### 2025-06-09: Bugfix - Resource Pool Tabellen fehlten + +**Problem:** +- Admin Panel zeigte "Internal Server Error" +- Dashboard Route versuchte auf `resource_pools` Tabelle zuzugreifen +- Tabelle existierte nicht in der Datenbank + +**Ursache:** +- Bei bereits existierender Datenbank wird init.sql nicht erneut ausgeführt +- Resource Pool Tabellen wurden erst später zum init.sql hinzugefügt +- Docker Container verwendeten noch die alte Datenbankstruktur + +**Lösung:** +1. Separates Script `create_resource_tables.sql` erstellt +2. Script manuell in der Datenbank ausgeführt +3. Alle 4 Resource-Tabellen erfolgreich erstellt: + - resource_pools + - resource_history + - resource_metrics + - license_resources + +**Status:** +- ✅ Admin Panel funktioniert wieder +- ✅ Dashboard zeigt Resource Pool Statistiken +- ✅ Alle Resource-Funktionen verfügbar + +**Empfehlung für Neuinstallationen:** +- Bei frischer Installation funktioniert alles automatisch +- Bei bestehenden Installationen: `create_resource_tables.sql` ausführen + +### 2025-06-09: Navigation vereinfacht + +**Änderung:** +- Navigationspunkte aus der schwarzen Navbar entfernt +- Links zu Lizenzen, Kunden, Ressourcen, Sessions, Backups und Log entfernt + +**Grund:** +- Cleaner Look mit nur Logo, Timer und Logout +- Alle Funktionen sind weiterhin über das Dashboard erreichbar +- Bessere Übersichtlichkeit und weniger Ablenkung + +**Geänderte Datei:** +- `v2_adminpanel/templates/base.html` - Navbar-Links auskommentiert + +**Status:** +- ✅ Navbar zeigt nur noch Logo, Session-Timer und Logout +- ✅ Navigation erfolgt über Dashboard und Buttons auf den jeweiligen Seiten +- ✅ Alle Funktionen bleiben erreichbar + +### 2025-06-09: Bugfix - Resource Report Einrückungsfehler + +**Problem:** +- Resource Report Route zeigte "Internal Server Error" +- UnboundLocalError: `report_type` wurde verwendet bevor es definiert war + +**Ursache:** +- Fehlerhafte Einrückung in der `resources_report()` Funktion +- `elif` und `else` Blöcke waren falsch eingerückt +- Variablen wurden außerhalb ihres Gültigkeitsbereichs verwendet + +**Lösung:** +- Korrekte Einrückung für alle Conditional-Blöcke wiederhergestellt +- Alle Report-Typen (usage, performance, compliance, inventory) richtig strukturiert +- Excel und CSV Export-Code korrekt eingerückt + +**Geänderte Datei:** +- `v2_adminpanel/app.py` - resources_report() Funktion korrigiert + +**Status:** +- ✅ Resource Report funktioniert wieder +- ✅ Alle 4 Report-Typen verfügbar +- ✅ Export als Excel und CSV möglich + +--- + +## Zusammenfassung der heutigen Arbeiten (2025-06-09) + +### 1. Resource Pool System Finalisierung +- **Ausgangslage**: Resource Pool war nur teilweise dokumentiert +- **Überraschung**: UI-Templates waren bereits vorhanden (nicht dokumentiert) +- **Ergänzt**: + - Test-Daten Script (`test_data_resources.sql`) + - Migration Script (`migrate_existing_licenses.sql`) +- **Status**: ✅ Vollständig implementiert + +### 2. Database Migration Bug +- **Problem**: Admin Panel zeigte "Internal Server Error" +- **Ursache**: Resource Pool Tabellen fehlten in bestehender DB +- **Lösung**: Separates Script `create_resource_tables.sql` erstellt +- **Status**: ✅ Behoben + +### 3. UI Cleanup +- **Änderung**: Navigation aus Navbar entfernt +- **Effekt**: Cleaner Look, Navigation nur über Dashboard +- **Status**: ✅ Implementiert + +### 4. Resource Report Bug +- **Problem**: Einrückungsfehler in `resources_report()` Funktion +- **Lösung**: Korrekte Einrückung wiederhergestellt +- **Status**: ✅ Behoben + +### Neue Dateien erstellt heute: +1. `v2_adminpanel/test_data_resources.sql` - 800 Test-Ressourcen + +### 2025-06-09: Bugfix - Resource Quarantäne Modal + +**Problem:** +- Quarantäne-Button funktionierte nicht +- Modal öffnete sich nicht beim Klick + +**Ursache:** +- Bootstrap 5 vs Bootstrap 4 API-Inkompatibilität +- Modal wurde mit Bootstrap 4 Syntax (`modal.modal('show')`) aufgerufen +- jQuery wurde nach Bootstrap geladen + +**Lösung:** +1. **JavaScript angepasst:** + - Von jQuery Modal-API zu nativer Bootstrap 5 Modal-API gewechselt + - `new bootstrap.Modal(element).show()` statt `$(element).modal('show')` + +2. **HTML-Struktur aktualisiert:** + - Modal-Close-Button: `data-bs-dismiss="modal"` statt `data-dismiss="modal"` + - `btn-close` Klasse statt custom close button + - Form-Klassen: `mb-3` statt `form-group`, `form-select` statt `form-control` für Select + +3. **Script-Reihenfolge korrigiert:** + - jQuery vor Bootstrap laden für korrekte Initialisierung + +**Geänderte Dateien:** +- `v2_adminpanel/templates/resources.html` +- `v2_adminpanel/templates/base.html` + +**Status:** ✅ Behoben + +### 2025-06-09: Resource Pool UI Redesign + +**Ziel:** +- Komplettes Redesign des Resource Pool Managements für bessere Benutzerfreundlichkeit +- Konsistentes Design mit dem Rest der Anwendung + +**Durchgeführte Änderungen:** + +1. **resources.html - Hauptübersicht:** + - Moderne Statistik-Karten mit Hover-Effekten + - Farbcodierte Progress-Bars mit Tooltips + - Verbesserte Tabelle mit Icons und Status-Badges + - Live-Filter mit sofortiger Suche + - Überarbeitete Quarantäne-Modal für Bootstrap 5 + - Responsive Design mit Grid-Layout + +2. **add_resources.html - Ressourcen hinzufügen:** + - 3-Schritt Wizard-ähnliches Interface + - Visueller Ressourcentyp-Selector mit Icons + - Live-Validierung mit Echtzeit-Feedback + - Statistik-Anzeige (Gültig/Duplikate/Ungültig) + - Formatierte Beispiele mit Erklärungen + - Verbesserte Fehlerbehandlung + +3. **resource_history.html - Historie:** + - Zentrierte Resource-Anzeige mit großen Icons + - Info-Grid Layout für Details + - Modernisierte Timeline mit Hover-Effekten + - Farbcodierte Action-Icons + - Verbesserte Darstellung von Details + +4. **resource_metrics.html - Metriken:** + - Dashboard-Style Metrik-Karten mit Icon-Badges + - Modernisierte Charts mit besseren Farben + - Performance-Tabellen mit Progress-Bars + - Trend-Indikatoren für Performance + - Responsives Grid-Layout + +**Design-Verbesserungen:** +- Konsistente Emoji-Icons für bessere visuelle Kommunikation +- Einheitliche Farbgebung (Blau/Lila/Grün für Ressourcentypen) +- Card-basiertes Layout mit Schatten und Hover-Effekten +- Bootstrap 5 kompatible Komponenten +- Verbesserte Typografie und Spacing + +**Technische Details:** +- Bootstrap 5 Modal-API statt jQuery +- CSS Grid für responsive Layouts +- Moderne Chart.js Konfiguration +- Optimierte JavaScript-Validierung + +**Geänderte Dateien:** +- `v2_adminpanel/templates/resources.html` +- `v2_adminpanel/templates/add_resources.html` +- `v2_adminpanel/templates/resource_history.html` +- `v2_adminpanel/templates/resource_metrics.html` + +**Status:** ✅ Abgeschlossen + +### 2025-06-09: Zusammenfassung der heutigen Arbeiten + +**Durchgeführte Aufgaben:** + +1. **Quarantäne-Funktion repariert:** + - Bootstrap 5 Modal-API implementiert + - data-bs-dismiss statt data-dismiss + - jQuery vor Bootstrap laden + +2. **Resource Pool UI komplett überarbeitet:** + - Alle 4 Templates modernisiert (resources, add_resources, resource_history, resource_metrics) + - Konsistentes Design mit Emoji-Icons + - Einheitliche Farbgebung (Blau/Lila/Grün) + - Bootstrap 5 kompatible Komponenten + - Responsive Grid-Layouts + +**Aktuelle Projekt-Status:** +- ✅ Admin Panel voll funktionsfähig +- ✅ Resource Pool Management mit modernem UI +- ✅ PostgreSQL mit allen Tabellen +- ✅ Nginx Reverse Proxy mit SSL +- ❌ Lizenzserver noch nicht implementiert (nur Platzhalter) + +**Nächste Schritte:** +- Lizenzserver implementieren +- API-Endpunkte für Lizenzvalidierung +- Heartbeat-System für Sessions +- Versionsprüfung implementieren +1. `v2_adminpanel/templates/base.html` - Navigation entfernt +2. `v2_adminpanel/app.py` - Resource Report Einrückung korrigiert +3. `JOURNAL.md` - Alle Änderungen dokumentiert + +### Offene Hauptaufgabe: +- **License Server API** - Noch komplett zu implementieren + - `/api/version` - Versionscheck + - `/api/validate` - Lizenzvalidierung + - `/api/heartbeat` - Session-Management + +### 2025-06-09: Resource Pool Internal Error behoben + +**Problem:** +- Internal Server Error beim Zugriff auf `/resources` +- NameError: name 'datetime' is not defined in Template + +**Ursache:** +- Fehlende `datetime` und `timedelta` Objekte im Template-Kontext +- Falsche Array-Indizes in resources.html für activity-Daten + +**Lösung:** +1. **app.py (Zeile 2797-2798):** + - `datetime=datetime` und `timedelta=timedelta` zu render_template hinzugefügt + +2. **resources.html (Zeile 484-490):** + - Array-Indizes korrigiert: + - activity[0] = action + - activity[1] = action_by + - activity[2] = action_at + - activity[3] = resource_type + - activity[4] = resource_value + - activity[5] = details + +**Geänderte Dateien:** +- `v2_adminpanel/app.py` +- `v2_adminpanel/templates/resources.html` + +**Status:** ✅ Behoben - Resource Pool funktioniert wieder einwandfrei + +### 2025-06-09: Passwort-Änderung und 2FA implementiert + +**Ziel:** +- Benutzer können ihr Passwort ändern +- Zwei-Faktor-Authentifizierung (2FA) mit TOTP +- Komplett kostenlose Lösung ohne externe Services + +**Implementierte Features:** + +1. **Datenbank-Erweiterung:** + - Neue `users` Tabelle mit Passwort-Hash und 2FA-Feldern + - Unterstützung für TOTP-Secrets und Backup-Codes + - Migration von Environment-Variablen zu Datenbank + +2. **Passwort-Management:** + - Sichere Passwort-Hashes mit bcrypt + - Passwort-Änderung mit Verifikation des alten Passworts + - Passwort-Stärke-Indikator im Frontend + +3. **2FA-Implementation:** + - TOTP-basierte 2FA (Google Authenticator, Authy kompatibel) + - QR-Code-Generierung für einfaches Setup + - 8 Backup-Codes für Notfallzugriff + - Backup-Codes als Textdatei downloadbar + +4. **Neue Routen:** + - `/profile` - Benutzerprofil mit Passwort und 2FA-Verwaltung + - `/verify-2fa` - 2FA-Verifizierung beim Login + - `/profile/setup-2fa` - 2FA-Einrichtung mit QR-Code + - `/profile/enable-2fa` - 2FA-Aktivierung + - `/profile/disable-2fa` - 2FA-Deaktivierung + - `/profile/change-password` - Passwort ändern + +5. **Sicherheits-Features:** + - Fallback zu Environment-Variablen für Rückwärtskompatibilität + - Session-Management für 2FA-Verifizierung + - Fehlgeschlagene 2FA-Versuche werden protokolliert + - Verwendete Backup-Codes werden entfernt + +**Verwendete Libraries (alle kostenlos):** +- `bcrypt` - Passwort-Hashing +- `pyotp` - TOTP-Generierung und Verifizierung +- `qrcode[pil]` - QR-Code-Generierung + +**Migration:** +- Script `migrate_users.py` erstellt für Migration existierender Benutzer +- Erhält bestehende Credentials aus Environment-Variablen +- Erstellt Datenbank-Einträge mit gehashten Passwörtern + +**Geänderte Dateien:** +- `v2_adminpanel/init.sql` - Users-Tabelle hinzugefügt +- `v2_adminpanel/requirements.txt` - Neue Dependencies +- `v2_adminpanel/app.py` - Auth-Funktionen und neue Routen +- `v2_adminpanel/migrate_users.py` - Migrations-Script (neu) +- `v2_adminpanel/templates/base.html` - Profil-Link hinzugefügt +- `v2_adminpanel/templates/profile.html` - Profil-Seite (neu) +- `v2_adminpanel/templates/verify_2fa.html` - 2FA-Verifizierung (neu) +- `v2_adminpanel/templates/setup_2fa.html` - 2FA-Setup (neu) +- `v2_adminpanel/templates/backup_codes.html` - Backup-Codes Anzeige (neu) + +**Status:** ✅ Vollständig implementiert + +### 2025-06-09: Internal Server Error behoben und UI-Design angepasst + +### 2025-06-09: Journal-Bereinigung und Projekt-Cleanup + +**Durchgeführte Aufgaben:** + +1. **Überflüssige SQL-Dateien gelöscht:** + - `create_resource_tables.sql` - War nur für Migrations nötig + - `migrate_existing_licenses.sql` - Keine alten Installationen vorhanden + - `sample_data.sql` - Testdaten nicht mehr benötigt + - `test_data_resources.sql` - Testdaten nicht mehr benötigt + +2. **Journal aktualisiert:** + - Veraltete Todo-Liste korrigiert (viele Features bereits implementiert) + - Passwörter aus Zugangsdaten entfernt (Sicherheit) + - "Bekannte Probleme" auf aktuellen Stand gebracht + - Neuer Abschnitt "Best Practices für Produktiv-Migration" hinzugefügt + +3. **Status-Klärungen:** + - Alle Daten sind Testdaten (PoC-Phase) + - 2FA ist implementiert und funktionsfähig + - Resource Pool System ist vollständig implementiert + - Port 8443 ist geschlossen (nur über Nginx erreichbar) + +**Noch zu erledigen:** +- Nginx Config anpassen (proxy_pass von https:// auf http://) +- License Server API implementieren (Hauptaufgabe) + +**Problem:** +- Internal Server Error nach Login wegen fehlender `users` Tabelle +- UI-Design der neuen 2FA-Seiten passte nicht zum Rest der Anwendung + +**Lösung:** + +1. **Datenbank-Fix:** + - Users-Tabelle wurde nicht automatisch erstellt + - Manuell mit SQL-Script nachgeholt + - Migration erfolgreich durchgeführt + - Beide Admin-User (rac00n, w@rh@mm3r) migriert + +2. **UI-Design Überarbeitung:** + - Profile-Seite im Dashboard-Stil mit Cards und Hover-Effekten + - 2FA-Setup mit nummerierten Schritten und modernem Card-Design + - Backup-Codes Seite mit Animation und verbessertem Layout + - Konsistente Farbgebung und Icons + - Verbesserte Benutzerführung mit visuellen Hinweisen + +**Design-Features:** +- Card-basiertes Layout mit Schatten-Effekten +- Hover-Animationen für bessere Interaktivität +- Farbcodierte Sicherheitsstatus-Anzeigen +- Passwort-Stärke-Indikator mit visueller Rückmeldung +- Responsive Design für alle Bildschirmgrößen +- Print-optimiertes Layout für Backup-Codes + +**Geänderte Dateien:** +- `v2_adminpanel/create_users_table.sql` - SQL für Users-Tabelle (temporär) + +### 2025-06-09: Journal-Umstrukturierung + +**Durchgeführte Änderungen:** + +1. **Dokumentation aufgeteilt:** + - `JOURNAL.md` - Enthält nur noch chronologische Änderungen (wie ein Tagebuch) + - `THE_ROAD_SO_FAR.md` - Neues Dokument mit aktuellem Status und Roadmap + +2. **THE_ROAD_SO_FAR.md erstellt mit:** + - Aktueller Status (was läuft bereits) + - Nächste Schritte (Priorität Hoch) + - Offene Aufgaben (Priorität Mittel) + - Nice-to-have Features + - Bekannte Probleme + - Deployment-Hinweise + +3. **JOURNAL.md bereinigt:** + - Todo-Listen entfernt (jetzt in THE_ROAD_SO_FAR.md) + - Nur noch chronologische Einträge + - Fokus auf "Was wurde gemacht" statt "Was muss gemacht werden" + +**Vorteile der Aufteilung:** +- Journal bleibt übersichtlich und wächst linear +- Status und Todos sind immer aktuell an einem Ort +- Klare Trennung zwischen Historie und Planung +- Einfacher für neue Entwickler einzusteigen + +### 2025-06-09: Nginx Config angepasst + +**Änderung:** +- proxy_pass für License Server von `https://license-server:8443` auf `http://license-server:8443` geändert +- `proxy_ssl_verify off` entfernt (nicht mehr nötig bei HTTP) +- WebSocket-Support hinzugefügt (für zukünftige Features) + +**Grund:** +- License Server läuft intern auf HTTP (wie Admin Panel) +- SSL-Termination erfolgt nur am Nginx +- Vereinfachte Konfiguration ohne doppelte SSL-Verschlüsselung + +**Hinweis:** +Docker-Container müssen neu gestartet werden, damit die Änderung wirksam wird: +```bash +docker-compose down +docker-compose up -d +``` +- `v2_adminpanel/templates/profile.html` - Komplett überarbeitet +- `v2_adminpanel/templates/setup_2fa.html` - Neues Step-by-Step Design +- `v2_adminpanel/templates/backup_codes.html` - Modernisiertes Layout + +**Status:** ✅ Abgeschlossen - Login funktioniert, UI im konsistenten Design + +### 2025-06-09: Lizenzschlüssel-Format geändert + +**Änderung:** +- Altes Format: `AF-YYYYMMFT-XXXX-YYYY-ZZZZ` (z.B. AF-202506F-V55Y-9DWE-GL5G) +- Neues Format: `AF-F-YYYYMM-XXXX-YYYY-ZZZZ` (z.B. AF-F-202506-V55Y-9DWE-GL5G) + +**Vorteile:** +- Klarere Struktur mit separatem Typ-Indikator +- Einfacher zu lesen und zu verstehen +- Typ (F/T) sofort im zweiten Block erkennbar + +**Geänderte Dateien:** +- `v2_adminpanel/app.py`: + - `generate_license_key()` - Generiert Keys im neuen Format + - `validate_license_key()` - Validiert Keys mit neuem Regex-Pattern +- `v2_adminpanel/templates/index.html`: + - Placeholder und Pattern für Input-Feld angepasst + - JavaScript charAt() Position für Typ-Prüfung korrigiert +- `v2_adminpanel/templates/batch_form.html`: + - Vorschau-Format für Batch-Generierung angepasst + +**Hinweis:** Alte Keys im bisherigen Format bleiben ungültig. Bei Bedarf könnte eine Migration oder Dual-Support implementiert werden. + +**Status:** ✅ Implementiert + +### 2025-06-09: Datenbank-Migration der Lizenzschlüssel + +**Durchgeführt:** +- Alle bestehenden Lizenzschlüssel in der Datenbank auf das neue Format migriert +- 18 Lizenzschlüssel erfolgreich konvertiert (16 Full, 2 Test) + +**Migration:** +- Von: `AF-YYYYMMFT-XXXX-YYYY-ZZZZ` +- Nach: `AF-F-YYYYMM-XXXX-YYYY-ZZZZ` + +**Beispiele:** +- Alt: `AF-202506F-V55Y-9DWE-GL5G` +- Neu: `AF-F-202506-V55Y-9DWE-GL5G` + +**Geänderte Dateien:** +- `v2_adminpanel/migrate_license_keys.sql` - Migrations-Script (temporär) +- `v2_adminpanel/fix_license_keys.sql` - Korrektur-Script (temporär) + +**Status:** ✅ Alle Lizenzschlüssel erfolgreich migriert + +### 2025-06-09: Kombinierte Kunden-Lizenz-Ansicht implementiert + +**Problem:** +- Umständliche Navigation zwischen Kunden- und Lizenzseiten +- Viel Hin-und-Her-Springen bei der Verwaltung +- Kontext-Verlust beim Wechseln zwischen Ansichten + +**Lösung:** +Master-Detail View mit 2-Spalten Layout implementiert + +**Phase 1-3 abgeschlossen:** +1. **Backend-Implementierung:** + - Neue Route `/customers-licenses` für kombinierte Ansicht + - API-Endpoints für AJAX: `/api/customer//licenses`, `/api/customer//quick-stats` + - API-Endpoint `/api/license//quick-edit` für Inline-Bearbeitung + - Optimierte SQL-Queries mit JOIN für Performance + +2. **Template-Erstellung:** + - Neues Template `customers_licenses.html` mit Master-Detail Layout + - Links: Kundenliste (30%) mit Suchfeld + - Rechts: Lizenzen des ausgewählten Kunden (70%) + - Responsive Design (Mobile: untereinander) + - JavaScript für dynamisches Laden ohne Seitenreload + - Keyboard-Navigation (↑↓ für Kundenwechsel) + +3. **Integration:** + - Dashboard: Neuer Button "Kunden & Lizenzen" + - Customers-Seite: Link zur kombinierten Ansicht + - Licenses-Seite: Link zur kombinierten Ansicht + - Lizenz-Erstellung: Unterstützung für vorausgewählten Kunden + - API /api/customers erweitert für Einzelabruf per ID + +**Features:** +- Live-Suche in Kundenliste +- Quick-Actions: Copy License Key, Toggle Status +- Modal für neue Lizenz direkt aus Kundenansicht +- URL-Update ohne Reload für Bookmarking +- Loading-States während AJAX-Calls +- Visuelles Feedback (aktiver Kunde hervorgehoben) + +**Noch ausstehend:** +- Phase 4: Inline-Edit für Lizenzdetails +- Phase 5: Erweiterte Error-Handling und Polish + +**Geänderte Dateien:** +- `v2_adminpanel/app.py` - Neue Routen und API-Endpoints +- `v2_adminpanel/templates/customers_licenses.html` - Neues Template +- `v2_adminpanel/templates/dashboard.html` - Neuer Button +- `v2_adminpanel/templates/customers.html` - Link zur kombinierten Ansicht +- `v2_adminpanel/templates/licenses.html` - Link zur kombinierten Ansicht +- `v2_adminpanel/templates/index.html` - Unterstützung für preselected_customer_id + +**Status:** ✅ Grundfunktionalität implementiert und funktionsfähig + +### 2025-06-09: Kombinierte Ansicht - Fertigstellung und TODOs aktualisiert + +**Abgeschlossen:** +- Phase 1-3 der kombinierten Kunden-Lizenz-Ansicht vollständig implementiert +- Master-Detail Layout funktioniert einwandfrei +- AJAX-basiertes Laden ohne Seitenreload +- Keyboard-Navigation mit Pfeiltasten +- Quick-Actions für Copy und Toggle Status +- Integration in alle relevanten Seiten + +**THE_ROAD_SO_FAR.md aktualisiert:** +- Kombinierte Ansicht als "Erledigt" markiert +- Von "In Arbeit" zu "Abgeschlossen" verschoben +- Status dokumentiert + +**Verbesserung gegenüber vorher:** +- Kein Hin-und-Her-Springen mehr zwischen Seiten +- Kontext bleibt erhalten beim Arbeiten mit Kunden +- Schnellere Navigation und bessere Übersicht +- Deutlich verbesserte User Experience + +**Optional für später (Phase 4-5):** +- Inline-Edit für weitere Felder +- Erweiterte Quick-Actions +- Session-basierte Filter-Persistenz + +Die Hauptproblematik der umständlichen Navigation ist damit gelöst! + +### 2025-06-09: Test-Flag für Lizenzen implementiert + +**Ziel:** +- Klare Trennung zwischen Testdaten und echten Produktivdaten +- Testdaten sollen von der Software ignoriert werden können +- Bessere Übersicht im Admin Panel + +**Durchgeführte Änderungen:** + +1. **Datenbank-Schema (init.sql):** + - Neue Spalte `is_test BOOLEAN DEFAULT FALSE` zur `licenses` Tabelle hinzugefügt + - Migration für bestehende Daten: Alle werden als `is_test = TRUE` markiert + - Index `idx_licenses_is_test` für bessere Performance + +2. **Backend (app.py):** + - Dashboard-Queries filtern Testdaten mit `WHERE is_test = FALSE` aus + - Lizenz-Erstellung: Neues Checkbox-Feld für Test-Markierung + - Lizenz-Bearbeitung: Test-Status kann geändert werden + - Export: Optional mit/ohne Testdaten (`?include_test=true`) + - Bulk-Operationen: Nur auf Live-Daten anwendbar + - Neue Filter in Lizenzliste: "🧪 Testdaten" und "🚀 Live-Daten" + +3. **Frontend Templates:** + - **index.html**: Checkbox "Als Testdaten markieren" bei Lizenzerstellung + - **edit_license.html**: Checkbox zum Ändern des Test-Status + - **licenses.html**: Badge 🧪 für Testdaten, neue Filteroptionen + - **dashboard.html**: Info-Box zeigt Anzahl der Testdaten + - **batch_form.html**: Option für Batch-Test-Lizenzen + +4. **Audit-Log Integration:** + - `is_test` Feld wird bei CREATE/UPDATE geloggt + - Nachvollziehbarkeit von Test/Live-Status-Änderungen + +**Technische Details:** +- Testdaten werden in allen Statistiken ausgefiltert +- License Server API wird Lizenzen mit `is_test = TRUE` ignorieren +- Resource Pool bleibt unverändert (kann Test- und Live-Ressourcen verwalten) + +**Migration der bestehenden Daten:** +```sql +UPDATE licenses SET is_test = TRUE; -- Alle aktuellen Daten sind Testdaten +``` + +**Status:** ✅ Implementiert + +### 2025-06-09: Test-Flag für Kunden und Resource Pools erweitert + +**Ziel:** +- Konsistentes Test-Daten-Management über alle Entitäten +- Kunden und Resource Pools können ebenfalls als Testdaten markiert werden +- Automatische Verknüpfung: Test-Kunde → Test-Lizenzen → Test-Ressourcen + +**Durchgeführte Änderungen:** + +1. **Datenbank-Schema erweitert:** + - `customers.is_test BOOLEAN DEFAULT FALSE` hinzugefügt + - `resource_pools.is_test BOOLEAN DEFAULT FALSE` hinzugefügt + - Indizes für bessere Performance erstellt + - Migrations in init.sql integriert + +2. **Backend (app.py) - Erweiterte Logik:** + - Dashboard: Separate Zähler für Test-Kunden und Test-Ressourcen + - Kunde-Erstellung: Erbt Test-Status von Lizenz + - Test-Kunde erzwingt Test-Lizenzen + - Resource-Zuweisung: Test-Lizenzen bekommen nur Test-Ressourcen + - Customer-Management mit is_test Filter + +3. **Frontend Updates:** + - **customers.html**: 🧪 Badge für Test-Kunden + - **edit_customer.html**: Checkbox für Test-Status + - **dashboard.html**: Erweiterte Test-Statistik (Lizenzen, Kunden, Ressourcen) + +4. **Geschäftslogik:** + - Wenn neuer Kunde bei Test-Lizenz erstellt wird → automatisch Test-Kunde + - Wenn Test-Kunde gewählt wird → Lizenz automatisch als Test markiert + - Resource Pool Allocation prüft Test-Status für korrekte Zuweisung + +**Migration der bestehenden Daten:** +```sql +UPDATE customers SET is_test = TRUE; -- 5 Kunden +UPDATE resource_pools SET is_test = TRUE; -- 20 Ressourcen +``` + +**Technische Details:** +- Konsistente Test/Live-Trennung über alle Ebenen +- Dashboard-Statistiken zeigen nur Live-Daten +- Test-Ressourcen werden nur Test-Lizenzen zugewiesen +- Alle bestehenden Daten sind jetzt als Test markiert + +**Status:** ✅ Vollständig implementiert + +### 2025-06-09 (17:20 - 18:13): Kunden-Lizenz-Verwaltung konsolidiert + +**Problem:** +- Kombinierte Ansicht `/customers-licenses` hatte Formatierungs- und Funktionsprobleme +- Kunden wurden nicht angezeigt +- Bootstrap Icons fehlten +- JavaScript-Fehler beim Modal +- Inkonsistentes Design im Vergleich zu anderen Seiten +- Testkunden-Filter wurde beim Navigieren nicht beibehalten + +**Durchgeführte Änderungen:** + +1. **Frontend-Fixes (base.html):** + - Bootstrap Icons CSS hinzugefügt: `bootstrap-icons@1.11.3/font/bootstrap-icons.min.css` + - Bootstrap JavaScript Bundle bereits vorhanden, Reihenfolge optimiert + +2. **customers_licenses.html komplett überarbeitet:** + - Container-Klasse von `container-fluid` auf `container py-5` geändert + - Emojis und Button-Styling vereinheitlicht (👥 Kunden & Lizenzen) + - Export-Dropdown wie in anderen Ansichten implementiert + - Card-Styling mit Schatten für einheitliches Design + - Checkbox "Testkunden anzeigen" mit Status-Beibehaltung + - JavaScript-Funktionen korrigiert: + - copyToClipboard mit event.currentTarget + - showNewLicenseModal mit Bootstrap Modal + - Header-Update beim AJAX-Kundenwechsel + - URL-Parameter `show_test` wird überall beibehalten + +3. **Backend-Anpassungen (app.py):** + - customers_licenses Route: Optional Testkunden anzeigen mit `show_test` Parameter + - Redirects von `/customers` und `/licenses` auf `/customers-licenses` implementiert + - Alte Route-Funktionen entfernt (kein toter Code mehr) + - edit_license und edit_customer: Redirects behalten show_test Parameter bei + - Dashboard-Links zeigen jetzt auf kombinierte Ansicht + +4. **Navigation optimiert:** + - Dashboard: Klick auf Kunden/Lizenzen-Statistik führt zur kombinierten Ansicht + - Alle Edit-Links behalten den show_test Parameter bei + - Konsistente User Experience beim Navigieren + +**Technische Details:** +- AJAX-Loading für dynamisches Laden der Lizenzen +- Keyboard-Navigation (↑↓) für Kundenliste +- Responsive Design mit Bootstrap Grid +- Modal-Dialoge für Bestätigungen +- Live-Suche in der Kundenliste + +**Resultat:** +- ✅ Einheitliches Design mit anderen Admin-Panel-Seiten +- ✅ Alle Funktionen arbeiten korrekt +- ✅ Testkunden-Filter bleibt erhalten +- ✅ Keine redundanten Views mehr +- ✅ Zentrale Verwaltung für Kunden und Lizenzen + +**Status:** ✅ Vollständig implementiert + +### 2025-06-09: Test-Daten Checkbox Persistenz implementiert + +**Problem:** +- Die "Testkunden anzeigen" Checkbox in `/customers-licenses` verlor ihren Status beim Navigieren zwischen Seiten +- Wenn Benutzer zu anderen Seiten (Resources, Audit Log, etc.) wechselten und zurückkehrten, war die Checkbox wieder deaktiviert +- Benutzer mussten die Checkbox jedes Mal neu aktivieren, was umständlich war + +**Lösung:** +- Globale JavaScript-Funktion `preserveShowTestParameter()` in base.html implementiert +- Die Funktion prüft beim Laden jeder Seite, ob `show_test=true` in der URL ist +- Wenn ja, wird dieser Parameter automatisch an alle internen Links angehängt +- Backend-Route `/create` wurde angepasst, um den Parameter bei Redirects beizubehalten + +**Technische Details:** +1. **base.html** - JavaScript-Funktion hinzugefügt: + - Läuft beim `DOMContentLoaded` Event + - Findet alle Links die mit "/" beginnen + - Fügt `show_test=true` Parameter hinzu wenn nicht bereits vorhanden + - Überspringt Fragment-Links (#) und Links die bereits den Parameter haben + +2. **app.py** - Route-Anpassung: + - `/create` Route behält jetzt `show_test` Parameter bei Redirects bei + - Andere Routen (edit_license, edit_customer) behalten Parameter bereits bei + +**Vorteile:** +- ✅ Konsistente User Experience beim Navigieren +- ✅ Keine manuelle Anpassung aller Links nötig +- ✅ Funktioniert automatisch für alle zukünftigen Links +- ✅ Minimaler Code-Overhead + +**Geänderte Dateien:** +- `v2_adminpanel/templates/base.html` +- `v2_adminpanel/app.py` + +**Status:** ✅ Vollständig implementiert + +### 2025-06-09: Bearbeiten-Button Fehler behoben + +**Problem:** +- Der "Bearbeiten" Button neben dem Kundennamen in der `/customers-licenses` Ansicht verursachte einen Internal Server Error +- Die URL-Konstruktion war fehlerhaft wenn kein `show_test` Parameter vorhanden war +- Die edit_customer.html Template hatte falsche Array-Indizes und veraltete Links + +**Ursache:** +1. Die href-Attribute wurden falsch konstruiert: + - Alt: `/customer/edit/ID{% if show_test %}?ref=customers-licenses&show_test=true{% endif %}` + - Problem: Ohne show_test fehlte das `?ref=customers-licenses` komplett + +2. Die SQL-Abfrage in edit_customer() holte nur 4 Felder, aber das Template erwartete 5: + - Query: `SELECT id, name, email, is_test` + - Template erwartete: `customer[3]` = created_at und `customer[4]` = is_test + +3. Veraltete Links zu `/customers` statt `/customers-licenses` + +**Lösung:** +1. URL-Konstruktion korrigiert in beiden Fällen: + - Neu: `/customer/edit/ID?ref=customers-licenses{% if show_test %}&show_test=true{% endif %}` + +2. SQL-Query erweitert um created_at: + - Neu: `SELECT id, name, email, created_at, is_test` + +3. Template-Indizes korrigiert: + - is_test Checkbox nutzt jetzt `customer[4]` + +4. Navigation-Links aktualisiert: + - Alle Links zeigen jetzt auf `/customers-licenses` mit show_test Parameter + +**Geänderte Dateien:** +- `v2_adminpanel/templates/customers_licenses.html` (Zeilen 103 und 295) +- `v2_adminpanel/app.py` (edit_customer Route) +- `v2_adminpanel/templates/edit_customer.html` + +**Status:** ✅ Behoben + +### 2025-06-09: Unnötigen Lizenz-Erstellungs-Popup entfernt + +**Änderung:** +- Der Bestätigungs-Popup "Möchten Sie eine neue Lizenz für KUNDENNAME erstellen?" wurde entfernt +- Klick auf "Neue Lizenz" Button führt jetzt direkt zur Lizenzerstellung + +**Technische Details:** +- Modal-HTML komplett entfernt +- `showNewLicenseModal()` Funktion vereinfacht - navigiert jetzt direkt zu `/create?customer_id=X` +- URL-Parameter (wie `show_test`) werden dabei beibehalten + +**Vorteile:** +- ✅ Ein Klick weniger für Benutzer +- ✅ Schnellerer Workflow +- ✅ Weniger Code zu warten + +**Geänderte Dateien:** +- `v2_adminpanel/templates/customers_licenses.html` + +**Status:** ✅ Implementiert + +### 2025-06-09: Testkunden-Checkbox bleibt jetzt bei Lizenz/Kunden-Bearbeitung erhalten + +**Problem:** +- Bei "Lizenz bearbeiten" ging der "Testkunden anzeigen" Haken verloren beim Zurückkehren +- Die Navigation-Links in edit_license.html zeigten auf `/licenses` statt `/customers-licenses` +- Der show_test Parameter wurde nur über den unsicheren Referrer übertragen + +**Lösung:** +1. **Navigation-Links korrigiert**: + - Alle Links zeigen jetzt auf `/customers-licenses` mit show_test Parameter + - Betrifft: "Zurück zur Übersicht" und "Abbrechen" Buttons + +2. **Hidden Form Field hinzugefügt**: + - Sowohl in edit_license.html als auch edit_customer.html + - Überträgt den show_test Parameter sicher beim POST + +3. **Route-Logik verbessert**: + - Parameter wird aus Form-Daten ODER GET-Parametern gelesen + - Nicht mehr auf unsicheren Referrer angewiesen + - Funktioniert sowohl bei Speichern als auch Abbrechen + +**Technische Details:** +- Templates prüfen `request.args.get('show_test')` für Navigation +- Hidden Input: `` +- Routes: `show_test = request.form.get('show_test') or request.args.get('show_test')` + +**Geänderte Dateien:** +- `v2_adminpanel/templates/edit_license.html` +- `v2_adminpanel/templates/edit_customer.html` +- `v2_adminpanel/app.py` (edit_license und edit_customer Routen) + +**Status:** ✅ Vollständig implementiert + +### 2025-06-09 22:02: Konsistente Sortierung bei Status-Toggle + +**Problem:** +- Beim Klicken auf den An/Aus-Knopf (Status-Toggle) in der Kunden & Lizenzen Ansicht änderte sich die Reihenfolge der Lizenzen +- Dies war verwirrend für Benutzer, da die Position der gerade bearbeiteten Lizenz springen konnte + +**Ursache:** +- Die Sortierung `ORDER BY l.created_at DESC` war nicht stabil genug +- Bei gleichem Erstellungszeitpunkt konnte die Datenbank die Reihenfolge inkonsistent zurückgeben + +**Lösung:** +- Sekundäres Sortierkriterium hinzugefügt: `ORDER BY l.created_at DESC, l.id DESC` +- Dies stellt sicher, dass bei gleichem Erstellungsdatum nach ID sortiert wird +- Die Reihenfolge bleibt jetzt konsistent, auch nach Status-Änderungen + +**Geänderte Dateien:** +- `v2_adminpanel/app.py`: + - Zeile 2278: `/customers-licenses` Route + - Zeile 2319: `/api/customer//licenses` API-Route + +### 2025-06-10 00:01: Verbesserte Integration zwischen Kunden & Lizenzen und Resource Pool + +**Problem:** +- Umständliche Navigation zwischen Kunden & Lizenzen und Resource Pool Bereichen +- Keine direkte Verbindung zwischen beiden Ansichten +- Benutzer mussten ständig zwischen verschiedenen Seiten hin- und herspringen + +**Implementierte Lösung - 5 Phasen:** + +1. **Phase 1: Ressourcen-Details in Kunden & Lizenzen Ansicht** + - API `/api/customer/{id}/licenses` erweitert um konkrete Ressourcen-Informationen + - Neue API `/api/license/{id}/resources` für detaillierte Ressourcen einer Lizenz + - Anzeige der zugewiesenen Ressourcen mit Info-Buttons und Modal-Dialogen + - Klickbare Links zu Ressourcen-Details im Resource Pool + +2. **Phase 2: Quick-Actions für Ressourcenverwaltung** + - "Ressourcen verwalten" Button (Zahnrad-Icon) bei jeder Lizenz + - Modal mit Übersicht aller zugewiesenen Ressourcen + - Vorbereitung für Quarantäne-Funktionen und Ressourcen-Austausch + +3. **Phase 3: Ressourcen-Preview bei Lizenzerstellung** + - Live-Anzeige verfügbarer Ressourcen beim Ändern der Anzahl + - Erweiterte Verfügbarkeitsanzeige mit Badges (OK/Niedrig/Kritisch) + - Warnungen bei niedrigem Bestand mit visuellen Hinweisen + - Fortschrittsbalken zur Visualisierung der Verfügbarkeit + +4. **Phase 4: Dashboard-Integration** + - Resource Pool Widget mit erweiterten Links + - Kritische Warnungen bei < 50 Ressourcen mit "Auffüllen" Button + - Direkte Navigation zu gefilterten Ansichten (nach Typ/Status) + - Verbesserte visuelle Darstellung mit Tooltips + +5. **Phase 5: Bidirektionale Navigation** + - Von Resource Pool: Links zu Kunden/Lizenzen bei zugewiesenen Ressourcen + - "Zurück zu Kunden" Button wenn von Kunden & Lizenzen kommend + - Navigation-Links im Dashboard für schnellen Zugriff + - SQL-Query erweitert um customer_id für direkte Verlinkung + +**Technische Details:** +- JavaScript-Funktionen für Modal-Dialoge und Ressourcen-Details +- Erweiterte SQL-Queries mit JOINs für Ressourcen-Informationen +- Bootstrap 5 Tooltips und Modals für bessere UX +- Globale Variable `currentLicenses` für Caching der Lizenzdaten + +**Geänderte Dateien:** +- `v2_adminpanel/app.py` - Neue APIs und erweiterte Queries +- `v2_adminpanel/templates/customers_licenses.html` - Ressourcen-Details und Modals +- `v2_adminpanel/templates/index.html` - Erweiterte Verfügbarkeitsanzeige +- `v2_adminpanel/templates/dashboard.html` - Verbesserte Resource Pool Integration +- `v2_adminpanel/templates/resources.html` - Bidirektionale Navigation + +**Status:** ✅ Alle 5 Phasen erfolgreich implementiert + +### 2025-06-10 00:15: IP-Adressen-Erfassung hinter Reverse Proxy korrigiert + +**Problem:** +- Flask-App erfasste nur die Docker-interne IP-Adresse von Nginx (172.19.0.5) +- Echte Client-IPs wurden nicht in Audit-Logs und Login-Attempts gespeichert +- Nginx setzte die Header korrekt, aber Flask las sie nicht aus + +**Ursache:** +- Flask verwendet standardmäßig nur `request.remote_addr` +- Dies gibt bei einem Reverse Proxy nur die Proxy-IP zurück +- Die Header `X-Real-IP` und `X-Forwarded-For` wurden ignoriert + +**Lösung:** +1. **ProxyFix Middleware** hinzugefügt für korrekte Header-Verarbeitung +2. **get_client_ip() Funktion** angepasst: + - Prüft zuerst `X-Real-IP` Header + - Dann `X-Forwarded-For` Header (nimmt erste IP bei mehreren) + - Fallback auf `request.remote_addr` +3. **Debug-Logging** für IP-Erfassung hinzugefügt +4. **Alle `request.remote_addr` Aufrufe** durch `get_client_ip()` ersetzt + +**Technische Details:** +```python +# ProxyFix für korrekte IP-Adressen +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) + +# Verbesserte IP-Erfassung +def get_client_ip(): + if request.headers.get('X-Real-IP'): + return request.headers.get('X-Real-IP') + elif request.headers.get('X-Forwarded-For'): + return request.headers.get('X-Forwarded-For').split(',')[0].strip() + else: + return request.remote_addr +``` + +**Geänderte Dateien:** +- `v2_adminpanel/app.py` - ProxyFix und verbesserte IP-Erfassung + +**Status:** ✅ Implementiert - Neue Aktionen erfassen jetzt echte Client-IPs + +### 2025-06-10 00:30: Docker ENV Legacy-Format Warnungen behoben + +**Problem:** +- Docker Build zeigte Warnungen: "LegacyKeyValueFormat: ENV key=value should be used" +- Veraltetes Format `ENV KEY VALUE` wurde in Dockerfiles verwendet + +**Lösung:** +- Alle ENV-Anweisungen auf neues Format `ENV KEY=VALUE` umgestellt +- Betraf hauptsächlich v2_postgres/Dockerfile mit 3 ENV-Zeilen + +**Geänderte Dateien:** +- `v2_postgres/Dockerfile` - ENV-Format modernisiert + +**Beispiel der Änderung:** +```dockerfile +# Alt (Legacy): +ENV LANG de_DE.UTF-8 +ENV LANGUAGE de_DE:de + +# Neu (Modern): +ENV LANG=de_DE.UTF-8 +ENV LANGUAGE=de_DE:de +``` + +**Status:** ✅ Alle Dockerfiles verwenden jetzt das moderne ENV-Format + **Status:** ✅ Behoben \ No newline at end of file diff --git a/v2/.env b/v2/.env index 3a27e3f..e834125 100644 --- a/v2/.env +++ b/v2/.env @@ -1,56 +1,56 @@ -# PostgreSQL-Datenbank -POSTGRES_DB=meinedatenbank -POSTGRES_USER=adminuser -POSTGRES_PASSWORD=supergeheimespasswort - -# Admin-Panel Zugangsdaten -ADMIN1_USERNAME=rac00n -ADMIN1_PASSWORD=1248163264 -ADMIN2_USERNAME=w@rh@mm3r -ADMIN2_PASSWORD=Warhammer123! - -# Lizenzserver API Key für Authentifizierung - - -# Domains (können von der App ausgewertet werden, z. B. für Links oder CORS) -API_DOMAIN=api-software-undso.z5m7q9dk3ah2v1plx6ju.com -ADMIN_PANEL_DOMAIN=admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com - -# ===================== OPTIONALE VARIABLEN ===================== - -# JWT für API-Auth -# JWT_SECRET=geheimer_token_schlüssel - -# E-Mail Konfiguration (z. B. bei Ablaufwarnungen) -# MAIL_SERVER=smtp.meinedomain.de -# MAIL_PORT=587 -# MAIL_USERNAME=deinemail -# MAIL_PASSWORD=geheim -# MAIL_FROM=no-reply@meinedomain.de - -# Logging -# LOG_LEVEL=info - -# Erlaubte CORS-Domains (für Web-Frontend) -# ALLOWED_ORIGINS=https://admin.meinedomain.de - -# ===================== VERSION ===================== - -# 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= - -# ===================== CAPTCHA KONFIGURATION ===================== - -# Google reCAPTCHA v2 Keys (https://www.google.com/recaptcha/admin) -# Für PoC-Phase auskommentiert - CAPTCHA wird übersprungen wenn Keys fehlen -# RECAPTCHA_SITE_KEY=your-site-key-here -# RECAPTCHA_SECRET_KEY=your-secret-key-here +# PostgreSQL-Datenbank +POSTGRES_DB=meinedatenbank +POSTGRES_USER=adminuser +POSTGRES_PASSWORD=supergeheimespasswort + +# Admin-Panel Zugangsdaten +ADMIN1_USERNAME=rac00n +ADMIN1_PASSWORD=1248163264 +ADMIN2_USERNAME=w@rh@mm3r +ADMIN2_PASSWORD=Warhammer123! + +# Lizenzserver API Key für Authentifizierung + + +# Domains (können von der App ausgewertet werden, z. B. für Links oder CORS) +API_DOMAIN=api-software-undso.z5m7q9dk3ah2v1plx6ju.com +ADMIN_PANEL_DOMAIN=admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com + +# ===================== OPTIONALE VARIABLEN ===================== + +# JWT für API-Auth +# JWT_SECRET=geheimer_token_schlüssel + +# E-Mail Konfiguration (z. B. bei Ablaufwarnungen) +# MAIL_SERVER=smtp.meinedomain.de +# MAIL_PORT=587 +# MAIL_USERNAME=deinemail +# MAIL_PASSWORD=geheim +# MAIL_FROM=no-reply@meinedomain.de + +# Logging +# LOG_LEVEL=info + +# Erlaubte CORS-Domains (für Web-Frontend) +# ALLOWED_ORIGINS=https://admin.meinedomain.de + +# ===================== VERSION ===================== + +# 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= + +# ===================== CAPTCHA KONFIGURATION ===================== + +# Google reCAPTCHA v2 Keys (https://www.google.com/recaptcha/admin) +# Für PoC-Phase auskommentiert - CAPTCHA wird übersprungen wenn Keys fehlen +# RECAPTCHA_SITE_KEY=your-site-key-here +# RECAPTCHA_SECRET_KEY=your-secret-key-here diff --git a/v2/docker-compose.yaml b/v2/docker-compose.yaml index 5ab8942..fe2acee 100644 --- a/v2/docker-compose.yaml +++ b/v2/docker-compose.yaml @@ -1,90 +1,90 @@ -services: - postgres: - build: - context: ../v2_postgres - container_name: db - restart: always - env_file: .env - environment: - POSTGRES_HOST: postgres - POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=de_DE.UTF-8' - POSTGRES_COLLATE: 'de_DE.UTF-8' - POSTGRES_CTYPE: 'de_DE.UTF-8' - TZ: Europe/Berlin - PGTZ: Europe/Berlin - volumes: - # Persistente Speicherung der Datenbank auf dem Windows-Host - - postgres_data:/var/lib/postgresql/data - # Init-Skript für Tabellen - - ../v2_adminpanel/init.sql:/docker-entrypoint-initdb.d/init.sql - networks: - - internal_net - deploy: - resources: - limits: - cpus: '2' - memory: 4g - - license-server: - build: - context: ../v2_lizenzserver - container_name: license-server - restart: always - # Port-Mapping entfernt - nur noch über Nginx erreichbar - env_file: .env - environment: - TZ: Europe/Berlin - depends_on: - - postgres - networks: - - internal_net - deploy: - resources: - limits: - cpus: '2' - memory: 4g - - admin-panel: - build: - context: ../v2_adminpanel - container_name: admin-panel - restart: always - # Port-Mapping entfernt - nur über nginx erreichbar - env_file: .env - environment: - TZ: Europe/Berlin - depends_on: - - postgres - networks: - - internal_net - volumes: - # Backup-Verzeichnis - - ../backups:/app/backups - deploy: - resources: - limits: - cpus: '2' - memory: 4g - - nginx: - build: - context: ../v2_nginx - container_name: nginx-proxy - restart: always - ports: - - "80:80" - - "443:443" - environment: - TZ: Europe/Berlin - depends_on: - - admin-panel - - license-server - networks: - - internal_net - -networks: - internal_net: - driver: bridge - -volumes: - postgres_data: +services: + postgres: + build: + context: ../v2_postgres + container_name: db + restart: always + env_file: .env + environment: + POSTGRES_HOST: postgres + POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=de_DE.UTF-8' + POSTGRES_COLLATE: 'de_DE.UTF-8' + POSTGRES_CTYPE: 'de_DE.UTF-8' + TZ: Europe/Berlin + PGTZ: Europe/Berlin + volumes: + # Persistente Speicherung der Datenbank auf dem Windows-Host + - postgres_data:/var/lib/postgresql/data + # Init-Skript für Tabellen + - ../v2_adminpanel/init.sql:/docker-entrypoint-initdb.d/init.sql + networks: + - internal_net + deploy: + resources: + limits: + cpus: '2' + memory: 4g + + license-server: + build: + context: ../v2_lizenzserver + container_name: license-server + restart: always + # Port-Mapping entfernt - nur noch über Nginx erreichbar + env_file: .env + environment: + TZ: Europe/Berlin + depends_on: + - postgres + networks: + - internal_net + deploy: + resources: + limits: + cpus: '2' + memory: 4g + + admin-panel: + build: + context: ../v2_adminpanel + container_name: admin-panel + restart: always + # Port-Mapping entfernt - nur über nginx erreichbar + env_file: .env + environment: + TZ: Europe/Berlin + depends_on: + - postgres + networks: + - internal_net + volumes: + # Backup-Verzeichnis + - ../backups:/app/backups + deploy: + resources: + limits: + cpus: '2' + memory: 4g + + nginx: + build: + context: ../v2_nginx + container_name: nginx-proxy + restart: always + ports: + - "80:80" + - "443:443" + environment: + TZ: Europe/Berlin + depends_on: + - admin-panel + - license-server + networks: + - internal_net + +networks: + internal_net: + driver: bridge + +volumes: + postgres_data: diff --git a/v2_adminpanel/Dockerfile b/v2_adminpanel/Dockerfile index cee53bf..dde0146 100644 --- a/v2_adminpanel/Dockerfile +++ b/v2_adminpanel/Dockerfile @@ -1,33 +1,33 @@ -FROM python:3.11-slim - -# Locale für deutsche Sprache und UTF-8 setzen -ENV LANG=de_DE.UTF-8 -ENV LC_ALL=de_DE.UTF-8 -ENV PYTHONIOENCODING=utf-8 - -# Zeitzone auf Europe/Berlin setzen -ENV TZ=Europe/Berlin - -WORKDIR /app - -# System-Dependencies inkl. PostgreSQL-Tools installieren -RUN apt-get update && apt-get install -y \ - locales \ - postgresql-client \ - tzdata \ - && sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \ - && locale-gen \ - && update-locale LANG=de_DE.UTF-8 \ - && ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \ - && echo "Europe/Berlin" > /etc/timezone \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -EXPOSE 5000 - -CMD ["python", "app.py"] +FROM python:3.11-slim + +# Locale für deutsche Sprache und UTF-8 setzen +ENV LANG=de_DE.UTF-8 +ENV LC_ALL=de_DE.UTF-8 +ENV PYTHONIOENCODING=utf-8 + +# Zeitzone auf Europe/Berlin setzen +ENV TZ=Europe/Berlin + +WORKDIR /app + +# System-Dependencies inkl. PostgreSQL-Tools installieren +RUN apt-get update && apt-get install -y \ + locales \ + postgresql-client \ + tzdata \ + && sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \ + && locale-gen \ + && update-locale LANG=de_DE.UTF-8 \ + && ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \ + && echo "Europe/Berlin" > /etc/timezone \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/v2_adminpanel/__pycache__/app.cpython-312.pyc b/v2_adminpanel/__pycache__/app.cpython-312.pyc index ae11b74..99427c4 100644 Binary files a/v2_adminpanel/__pycache__/app.cpython-312.pyc and b/v2_adminpanel/__pycache__/app.cpython-312.pyc differ diff --git a/v2_adminpanel/app.py b/v2_adminpanel/app.py index 0622714..4e6204a 100644 --- a/v2_adminpanel/app.py +++ b/v2_adminpanel/app.py @@ -77,10 +77,24 @@ logging.basicConfig(level=logging.INFO) # Import and register blueprints from routes.auth_routes import auth_bp from routes.admin_routes import admin_bp +from routes.license_routes import license_bp +from routes.customer_routes import customer_bp +from routes.resource_routes import resource_bp +from routes.session_routes import session_bp +from routes.batch_routes import batch_bp +from routes.api_routes import api_bp +from routes.export_routes import export_bp -# Temporarily comment out blueprints to avoid conflicts -# app.register_blueprint(auth_bp) -# app.register_blueprint(admin_bp) +# Register blueprints +app.register_blueprint(auth_bp) +app.register_blueprint(admin_bp) +app.register_blueprint(license_bp) +app.register_blueprint(customer_bp) +app.register_blueprint(resource_bp) +app.register_blueprint(session_bp) +app.register_blueprint(batch_bp) +app.register_blueprint(api_bp) +app.register_blueprint(export_bp) # Scheduled Backup Job @@ -136,213 +150,213 @@ def verify_recaptcha(response): return False -@app.route("/login", methods=["GET", "POST"]) -def login(): +# @app.route("/login", methods=["GET", "POST"]) +# def login(): # Timing-Attack Schutz - Start Zeit merken - start_time = time.time() + # start_time = time.time() # IP-Adresse ermitteln - ip_address = get_client_ip() + # ip_address = get_client_ip() # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") + # is_blocked, blocked_until = check_ip_blocked(ip_address) + # if is_blocked: + # time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 + # error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." + # return render_template("login.html", error=error_msg, error_type="blocked") # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) + # attempt_count = get_login_attempts(ip_address) - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") + # if request.method == "POST": + # username = request.form.get("username") + # password = request.form.get("password") + # captcha_response = request.form.get("g-recaptcha-response") # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = config.RECAPTCHA_SITE_KEY - if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - if not captcha_response: + # recaptcha_site_key = config.RECAPTCHA_SITE_KEY + # if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: + # if not captcha_response: # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA ERFORDERLICH!", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) + # elapsed = time.time() - start_time + # if elapsed < 1.0: + # time.sleep(1.0 - elapsed) + # return render_template("login.html", + # error="CAPTCHA ERFORDERLICH!", + # show_captcha=True, + # error_type="captcha", + # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + # recaptcha_site_key=recaptcha_site_key) # CAPTCHA validieren - if not verify_recaptcha(captcha_response): + # if not verify_recaptcha(captcha_response): # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) + # elapsed = time.time() - start_time + # if elapsed < 1.0: + # time.sleep(1.0 - elapsed) + # return render_template("login.html", + # error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", + # show_captcha=True, + # error_type="captcha", + # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + # recaptcha_site_key=recaptcha_site_key) # Check user in database first, fallback to env vars - user = get_user_by_username(username) - login_success = False - needs_2fa = False + # user = get_user_by_username(username) + # login_success = False + # needs_2fa = False - if user: + # if user: # Database user authentication - if verify_password(password, user['password_hash']): - login_success = True - needs_2fa = user['totp_enabled'] - else: + # if verify_password(password, user['password_hash']): + # login_success = True + # needs_2fa = user['totp_enabled'] + # else: # Fallback to environment variables for backward compatibility - if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: - login_success = True + # if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: + # login_success = True # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) + # elapsed = time.time() - start_time + # if elapsed < 1.0: + # time.sleep(1.0 - elapsed) - if login_success: + # if login_success: # Erfolgreicher Login - if needs_2fa: + # if needs_2fa: # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('verify_2fa')) - else: + # session['temp_username'] = username + # session['temp_user_id'] = user['id'] + # session['awaiting_2fa'] = True + # return redirect(url_for('verify_2fa')) + # else: # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('dashboard')) - else: + # session.permanent = True # Aktiviert das Timeout + # session['logged_in'] = True + # session['username'] = username + # session['user_id'] = user['id'] if user else None + # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + # reset_login_attempts(ip_address) + # log_audit('LOGIN_SUCCESS', 'user', + # additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") + # return redirect(url_for('dashboard')) + # else: # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) + # error_message = record_failed_attempt(ip_address, username) + # new_attempt_count = get_login_attempts(ip_address) # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") + # is_now_blocked, _ = check_ip_blocked(ip_address) + # if is_now_blocked: + # log_audit('LOGIN_BLOCKED', 'security', + # additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - error_type="failed", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + # return render_template("login.html", + # error=error_message, + # show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + # error_type="failed", + # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), + # recaptcha_site_key=config.RECAPTCHA_SITE_KEY) # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + # return render_template("login.html", + # show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + # recaptcha_site_key=config.RECAPTCHA_SITE_KEY) -@app.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('login')) +# @app.route("/logout") +# def logout(): + # username = session.get('username', 'unknown') + # log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") + # session.pop('logged_in', None) + # session.pop('username', None) + # session.pop('user_id', None) + # session.pop('temp_username', None) + # session.pop('temp_user_id', None) + # session.pop('awaiting_2fa', None) + # return redirect(url_for('login')) -@app.route("/verify-2fa", methods=["GET", "POST"]) -def verify_2fa(): - if not session.get('awaiting_2fa'): - return redirect(url_for('login')) +# @app.route("/verify-2fa", methods=["GET", "POST"]) +# def verify_2fa(): + # if not session.get('awaiting_2fa'): + # return redirect(url_for('login')) - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') + # if request.method == "POST": + # token = request.form.get('token', '').replace(' ', '') + # username = session.get('temp_username') + # user_id = session.get('temp_user_id') - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('login')) + # if not username or not user_id: + # flash('Session expired. Please login again.', 'error') + # return redirect(url_for('login')) - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('login')) + # user = get_user_by_username(username) + # if not user: + # flash('User not found.', 'error') + # return redirect(url_for('login')) # Check if it's a backup code - if len(token) == 8 and token.isupper(): + # if len(token) == 8 and token.isupper(): # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): + # backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] + # if verify_backup_code(token, backup_codes): # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) + # code_hash = hash_backup_code(token) + # backup_codes.remove(code_hash) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - conn.commit() - cur.close() - conn.close() + # conn = get_connection() + # cur = conn.cursor() + # cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", + # (json.dumps(backup_codes), user_id)) + # conn.commit() + # cur.close() + # conn.close() # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) + # session.permanent = True + # session['logged_in'] = True + # session['username'] = username + # session['user_id'] = user_id + # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + # session.pop('temp_username', None) + # session.pop('temp_user_id', None) + # session.pop('awaiting_2fa', None) - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('dashboard')) - else: + # flash('Login successful using backup code. Please generate new backup codes.', 'warning') + # log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") + # return redirect(url_for('dashboard')) + # else: # Try TOTP token - if verify_totp(user['totp_secret'], token): + # if verify_totp(user['totp_secret'], token): # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) + # session.permanent = True + # session['logged_in'] = True + # session['username'] = username + # session['user_id'] = user_id + # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + # session.pop('temp_username', None) + # session.pop('temp_user_id', None) + # session.pop('awaiting_2fa', None) - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('dashboard')) + # log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") + # return redirect(url_for('dashboard')) # Failed verification - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - conn.commit() - cur.close() - conn.close() - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') + # conn = get_connection() + # cur = conn.cursor() + # cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", + # (datetime.now(), user_id)) + # conn.commit() + # cur.close() + # conn.close() + + # flash('Invalid authentication code. Please try again.', 'error') + # log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") + + # return render_template('verify_2fa.html') -@app.route("/profile") -@login_required +# @app.route("/profile") +# @login_required def profile(): user = get_user_by_username(session['username']) if not user: @@ -351,8 +365,8 @@ def profile(): return redirect(url_for('dashboard')) return render_template('profile.html', user=user) -@app.route("/profile/change-password", methods=["POST"]) -@login_required +# @app.route("/profile/change-password", methods=["POST"]) +# @login_required def change_password(): current_password = request.form.get('current_password') new_password = request.form.get('new_password') @@ -389,8 +403,8 @@ def change_password(): flash('Password changed successfully.', 'success') return redirect(url_for('profile')) -@app.route("/profile/setup-2fa") -@login_required +# @app.route("/profile/setup-2fa") +# @login_required def setup_2fa(): user = get_user_by_username(session['username']) @@ -409,8 +423,8 @@ def setup_2fa(): totp_secret=totp_secret, qr_code=qr_code) -@app.route("/profile/enable-2fa", methods=["POST"]) -@login_required +# @app.route("/profile/enable-2fa", methods=["POST"]) +# @login_required def enable_2fa(): token = request.form.get('token', '').replace(' ', '') totp_secret = session.get('temp_totp_secret') @@ -447,8 +461,8 @@ def enable_2fa(): # Show backup codes return render_template('backup_codes.html', backup_codes=backup_codes) -@app.route("/profile/disable-2fa", methods=["POST"]) -@login_required +# @app.route("/profile/disable-2fa", methods=["POST"]) +# @login_required def disable_2fa(): password = request.form.get('password') user = get_user_by_username(session['username']) @@ -474,8 +488,8 @@ def disable_2fa(): flash('2FA has been disabled for your account.', 'success') return redirect(url_for('profile')) -@app.route("/heartbeat", methods=['POST']) -@login_required +# @app.route("/heartbeat", methods=['POST']) +# @login_required def heartbeat(): """Endpoint für Session Keep-Alive - aktualisiert last_activity""" # Aktualisiere last_activity nur wenn explizit angefordert @@ -489,8 +503,8 @@ def heartbeat(): 'username': session.get('username') }) -@app.route("/api/generate-license-key", methods=['POST']) -@login_required +# @app.route("/api/generate-license-key", methods=['POST']) +# @login_required def api_generate_key(): """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" try: @@ -534,8 +548,8 @@ def api_generate_key(): 'error': 'Fehler bei der Key-Generierung' }), 500 -@app.route("/api/customers", methods=['GET']) -@login_required +# @app.route("/api/customers", methods=['GET']) +# @login_required def api_customers(): """API Endpoint für die Kundensuche mit Select2""" try: @@ -645,8 +659,8 @@ def api_customers(): 'error': 'Fehler bei der Kundensuche' }), 500 -@app.route("/") -@login_required +# @app.route("/") +# @login_required def dashboard(): conn = get_connection() cur = conn.cursor() @@ -875,8 +889,8 @@ def dashboard(): resource_warning=resource_warning, username=session.get('username')) -@app.route("/create", methods=["GET", "POST"]) -@login_required +# @app.route("/create", methods=["GET", "POST"]) +# @login_required def create_license(): if request.method == "POST": customer_id = request.form.get("customer_id") @@ -1106,8 +1120,8 @@ def create_license(): preselected_customer_id = request.args.get('customer_id', type=int) return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) -@app.route("/batch", methods=["GET", "POST"]) -@login_required +# @app.route("/batch", methods=["GET", "POST"]) +# @login_required def batch_licenses(): """Batch-Generierung mehrerer Lizenzen für einen Kunden""" if request.method == "POST": @@ -1361,8 +1375,8 @@ def batch_licenses(): # GET Request return render_template("batch_form.html") -@app.route("/batch/export") -@login_required +# @app.route("/batch/export") +# @login_required def export_batch(): """Exportiert die zuletzt generierten Batch-Lizenzen""" batch_data = session.get('batch_export') @@ -1400,14 +1414,14 @@ def export_batch(): download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" ) -@app.route("/licenses") -@login_required +# @app.route("/licenses") +# @login_required def licenses(): # Redirect zur kombinierten Ansicht return redirect("/customers-licenses") -@app.route("/license/edit/", methods=["GET", "POST"]) -@login_required +# @app.route("/license/edit/", methods=["GET", "POST"]) +# @login_required def edit_license(license_id): conn = get_connection() cur = conn.cursor() @@ -1498,8 +1512,8 @@ def edit_license(license_id): return render_template("edit_license.html", license=license, username=session.get('username')) -@app.route("/license/delete/", methods=["POST"]) -@login_required +# @app.route("/license/delete/", methods=["POST"]) +# @login_required def delete_license(license_id): conn = get_connection() cur = conn.cursor() @@ -1531,14 +1545,14 @@ def delete_license(license_id): return redirect("/licenses") -@app.route("/customers") -@login_required +# @app.route("/customers") +# @login_required def customers(): # Redirect zur kombinierten Ansicht return redirect("/customers-licenses") -@app.route("/customer/edit/", methods=["GET", "POST"]) -@login_required +# @app.route("/customer/edit/", methods=["GET", "POST"]) +# @login_required def edit_customer(customer_id): conn = get_connection() cur = conn.cursor() @@ -1621,8 +1635,8 @@ def edit_customer(customer_id): return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) -@app.route("/customer/create", methods=["GET", "POST"]) -@login_required +# @app.route("/customer/create", methods=["GET", "POST"]) +# @login_required def create_customer(): """Erstellt einen neuen Kunden ohne Lizenz""" if request.method == "POST": @@ -1676,8 +1690,8 @@ def create_customer(): # GET Request - Formular anzeigen return render_template("create_customer.html", username=session.get('username')) -@app.route("/customer/delete/", methods=["POST"]) -@login_required +# @app.route("/customer/delete/", methods=["POST"]) +# @login_required def delete_customer(customer_id): conn = get_connection() cur = conn.cursor() @@ -1714,8 +1728,8 @@ def delete_customer(customer_id): return redirect("/customers") -@app.route("/customers-licenses") -@login_required +# @app.route("/customers-licenses") +# @login_required def customers_licenses(): """Kombinierte Ansicht für Kunden und deren Lizenzen""" conn = get_connection() @@ -1807,8 +1821,8 @@ def customers_licenses(): licenses=licenses, show_test=show_test) -@app.route("/api/customer//licenses") -@login_required +# @app.route("/api/customer//licenses") +# @login_required def api_customer_licenses(customer_id): """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" conn = get_connection() @@ -1910,8 +1924,8 @@ def api_customer_licenses(customer_id): 'count': len(licenses) }) -@app.route("/api/customer//quick-stats") -@login_required +# @app.route("/api/customer//quick-stats") +# @login_required def api_customer_quick_stats(customer_id): """API-Endpoint für Schnellstatistiken eines Kunden""" conn = get_connection() @@ -1943,8 +1957,8 @@ def api_customer_quick_stats(customer_id): } }) -@app.route("/api/license//quick-edit", methods=['POST']) -@login_required +# @app.route("/api/license//quick-edit", methods=['POST']) +# @login_required def api_license_quick_edit(license_id): """API-Endpoint für schnelle Lizenz-Bearbeitung""" conn = get_connection() @@ -2013,8 +2027,8 @@ def api_license_quick_edit(license_id): conn.close() return jsonify({'success': False, 'error': str(e)}), 500 -@app.route("/api/license//resources") -@login_required +# @app.route("/api/license//resources") +# @login_required def api_license_resources(license_id): """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" conn = get_connection() @@ -2063,8 +2077,8 @@ def api_license_resources(license_id): conn.close() return jsonify({'success': False, 'error': str(e)}), 500 -@app.route("/sessions") -@login_required +# @app.route("/sessions") +# @login_required def sessions(): conn = get_connection() cur = conn.cursor() @@ -2145,8 +2159,8 @@ def sessions(): ended_order=ended_order, username=session.get('username')) -@app.route("/session/end/", methods=["POST"]) -@login_required +# @app.route("/session/end/", methods=["POST"]) +# @login_required def end_session(session_id): conn = get_connection() cur = conn.cursor() @@ -2164,8 +2178,8 @@ def end_session(session_id): return redirect("/sessions") -@app.route("/export/licenses") -@login_required +# @app.route("/export/licenses") +# @login_required def export_licenses(): conn = get_connection() cur = conn.cursor() @@ -2274,8 +2288,8 @@ def export_licenses(): download_name=f'{filename}.xlsx' ) -@app.route("/export/audit") -@login_required +# @app.route("/export/audit") +# @login_required def export_audit(): conn = get_connection() cur = conn.cursor() @@ -2398,8 +2412,8 @@ def export_audit(): download_name=f'{filename}.xlsx' ) -@app.route("/export/customers") -@login_required +# @app.route("/export/customers") +# @login_required def export_customers(): conn = get_connection() cur = conn.cursor() @@ -2502,8 +2516,8 @@ def export_customers(): download_name=f'{filename}.xlsx' ) -@app.route("/export/sessions") -@login_required +# @app.route("/export/sessions") +# @login_required def export_sessions(): conn = get_connection() cur = conn.cursor() @@ -2641,8 +2655,8 @@ def export_sessions(): download_name=f'{filename}.xlsx' ) -@app.route("/export/resources") -@login_required +# @app.route("/export/resources") +# @login_required def export_resources(): conn = get_connection() cur = conn.cursor() @@ -2770,8 +2784,8 @@ def export_resources(): download_name=f'{filename}.xlsx' ) -@app.route("/audit") -@login_required +# @app.route("/audit") +# @login_required def audit_log(): conn = get_connection() cur = conn.cursor() @@ -2864,8 +2878,8 @@ def audit_log(): order=order, username=session.get('username')) -@app.route("/backups") -@login_required +# @app.route("/backups") +# @login_required def backups(): """Zeigt die Backup-Historie an""" conn = get_connection() @@ -2899,8 +2913,8 @@ def backups(): last_backup=last_backup, username=session.get('username')) -@app.route("/backup/create", methods=["POST"]) -@login_required +# @app.route("/backup/create", methods=["POST"]) +# @login_required def create_backup_route(): """Erstellt ein manuelles Backup""" username = session.get('username') @@ -2917,8 +2931,8 @@ def create_backup_route(): 'message': f'Backup fehlgeschlagen: {result}' }), 500 -@app.route("/backup/restore/", methods=["POST"]) -@login_required +# @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') @@ -2936,8 +2950,8 @@ def restore_backup_route(backup_id): 'message': message }), 500 -@app.route("/backup/download/") -@login_required +# @app.route("/backup/download/") +# @login_required def download_backup(backup_id): """Lädt eine Backup-Datei herunter""" conn = get_connection() @@ -2968,8 +2982,8 @@ def download_backup(backup_id): return send_file(filepath, as_attachment=True, download_name=filename) -@app.route("/backup/delete/", methods=["DELETE"]) -@login_required +# @app.route("/backup/delete/", methods=["DELETE"]) +# @login_required def delete_backup(backup_id): """Löscht ein Backup""" conn = get_connection() @@ -3024,8 +3038,8 @@ def delete_backup(backup_id): cur.close() conn.close() -@app.route("/security/blocked-ips") -@login_required +# @app.route("/security/blocked-ips") +# @login_required def blocked_ips(): """Zeigt alle gesperrten IPs an""" conn = get_connection() @@ -3065,8 +3079,8 @@ def blocked_ips(): blocked_ips=blocked_ips_list, username=session.get('username')) -@app.route("/security/unblock-ip", methods=["POST"]) -@login_required +# @app.route("/security/unblock-ip", methods=["POST"]) +# @login_required def unblock_ip(): """Entsperrt eine IP-Adresse""" ip_address = request.form.get('ip_address') @@ -3091,8 +3105,8 @@ def unblock_ip(): return redirect(url_for('blocked_ips')) -@app.route("/security/clear-attempts", methods=["POST"]) -@login_required +# @app.route("/security/clear-attempts", methods=["POST"]) +# @login_required def clear_attempts(): """Löscht alle Login-Versuche für eine IP""" ip_address = request.form.get('ip_address') @@ -3107,8 +3121,8 @@ def clear_attempts(): return redirect(url_for('blocked_ips')) # API Endpoints for License Management -@app.route("/api/license//toggle", methods=["POST"]) -@login_required +# @app.route("/api/license//toggle", methods=["POST"]) +# @login_required def toggle_license_api(license_id): """Toggle license active status via API""" try: @@ -3139,8 +3153,8 @@ def toggle_license_api(license_id): except Exception as e: return jsonify({'success': False, 'message': str(e)}), 500 -@app.route("/api/licenses/bulk-activate", methods=["POST"]) -@login_required +# @app.route("/api/licenses/bulk-activate", methods=["POST"]) +# @login_required def bulk_activate_licenses(): """Activate multiple licenses at once""" try: @@ -3175,8 +3189,8 @@ def bulk_activate_licenses(): except Exception as e: return jsonify({'success': False, 'message': str(e)}), 500 -@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -@login_required +# @app.route("/api/licenses/bulk-deactivate", methods=["POST"]) +# @login_required def bulk_deactivate_licenses(): """Deactivate multiple licenses at once""" try: @@ -3211,8 +3225,8 @@ def bulk_deactivate_licenses(): except Exception as e: return jsonify({'success': False, 'message': str(e)}), 500 -@app.route("/api/license//devices") -@login_required +# @app.route("/api/license//devices") +# @login_required def get_license_devices(license_id): """Hole alle registrierten Geräte einer Lizenz""" try: @@ -3266,123 +3280,123 @@ def get_license_devices(license_id): logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 -@app.route("/api/license//register-device", methods=["POST"]) -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - try: - data = request.get_json() - hardware_id = data.get('hardware_id') - device_name = data.get('device_name', '') - operating_system = data.get('operating_system', '') +# @app.route("/api/license//register-device", methods=["POST"]) +# def register_device(license_id): + # """Registriere ein neues Gerät für eine Lizenz""" + # try: + # data = request.get_json() + # hardware_id = data.get('hardware_id') + # device_name = data.get('device_name', '') + # operating_system = data.get('operating_system', '') - if not hardware_id: - return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 + # if not hardware_id: + # return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - conn = get_connection() - cur = conn.cursor() + # conn = get_connection() + # cur = conn.cursor() # Prüfe ob Lizenz existiert und aktiv ist - cur.execute(""" - SELECT device_limit, is_active, valid_until - FROM licenses - WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() + # cur.execute(""" + # SELECT device_limit, is_active, valid_until + # FROM licenses + # WHERE id = %s + # """, (license_id,)) + # license_data = cur.fetchone() - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + # if not license_data: + # return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - device_limit, is_active, valid_until = license_data + # device_limit, is_active, valid_until = license_data # Prüfe ob Lizenz aktiv und gültig ist - if not is_active: - return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 + # if not is_active: + # return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 + # if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): + # return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 # Prüfe ob Gerät bereits registriert ist - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_id = %s AND hardware_id = %s - """, (license_id, hardware_id)) - existing_device = cur.fetchone() - - if existing_device: - device_id, is_device_active = existing_device - if is_device_active: + # cur.execute(""" + # SELECT id, is_active FROM device_registrations + # WHERE license_id = %s AND hardware_id = %s + # """, (license_id, hardware_id)) + # existing_device = cur.fetchone() + + # if existing_device: + # device_id, is_device_active = existing_device + # if is_device_active: # Gerät ist bereits aktiv, update last_seen - cur.execute(""" - UPDATE device_registrations - SET last_seen = CURRENT_TIMESTAMP, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - else: + # cur.execute(""" + # UPDATE device_registrations + # SET last_seen = CURRENT_TIMESTAMP, + # ip_address = %s, + # user_agent = %s + # WHERE id = %s + # """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + # conn.commit() + # return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) + # else: # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] + # cur.execute(""" + # SELECT COUNT(*) FROM device_registrations + # WHERE license_id = %s AND is_active = TRUE + # """, (license_id,)) + # active_count = cur.fetchone()[0] - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + # if active_count >= device_limit: + # return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 # Reaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = TRUE, - last_seen = CURRENT_TIMESTAMP, - deactivated_at = NULL, - deactivated_by = NULL, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) + # cur.execute(""" + # UPDATE device_registrations + # SET is_active = TRUE, + # last_seen = CURRENT_TIMESTAMP, + # deactivated_at = NULL, + # deactivated_by = NULL, + # ip_address = %s, + # user_agent = %s + # WHERE id = %s + # """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + # conn.commit() + # return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) # Neues Gerät - prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] + # cur.execute(""" + # SELECT COUNT(*) FROM device_registrations + # WHERE license_id = %s AND is_active = TRUE + # """, (license_id,)) + # active_count = cur.fetchone()[0] - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + # if active_count >= device_limit: + # return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (license_id, hardware_id, device_name, operating_system, - get_client_ip(), request.headers.get('User-Agent', ''))) - device_id = cur.fetchone()[0] + # cur.execute(""" + # INSERT INTO device_registrations + # (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) + # VALUES (%s, %s, %s, %s, %s, %s) + # RETURNING id + # """, (license_id, hardware_id, device_name, operating_system, + # get_client_ip(), request.headers.get('User-Agent', ''))) + # device_id = cur.fetchone()[0] - conn.commit() + # conn.commit() # Audit Log - log_audit('DEVICE_REGISTER', 'device', device_id, - new_values={'license_id': license_id, 'hardware_id': hardware_id}) + # log_audit('DEVICE_REGISTER', 'device', device_id, + # new_values={'license_id': license_id, 'hardware_id': hardware_id}) - cur.close() - conn.close() + # cur.close() + # conn.close() - return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) + # return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - except Exception as e: - logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 + # except Exception as e: + # logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") + # return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 -@app.route("/api/license//deactivate-device/", methods=["POST"]) -@login_required +# @app.route("/api/license//deactivate-device/", methods=["POST"]) +# @login_required def deactivate_device(license_id, device_id): """Deaktiviere ein registriertes Gerät""" try: @@ -3423,8 +3437,8 @@ def deactivate_device(license_id, device_id): logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 -@app.route("/api/licenses/bulk-delete", methods=["POST"]) -@login_required +# @app.route("/api/licenses/bulk-delete", methods=["POST"]) +# @login_required def bulk_delete_licenses(): """Delete multiple licenses at once""" try: @@ -3468,8 +3482,8 @@ def bulk_delete_licenses(): # ===================== RESOURCE POOL MANAGEMENT ===================== -@app.route('/resources') -@login_required +# @app.route('/resources') +# @login_required def resources(): """Resource Pool Hauptübersicht""" conn = get_connection() @@ -3608,8 +3622,8 @@ def resources(): datetime=datetime, timedelta=timedelta) -@app.route('/resources/add', methods=['GET', 'POST']) -@login_required +# @app.route('/resources/add', methods=['GET', 'POST']) +# @login_required def add_resources(): """Ressourcen zum Pool hinzufügen""" # Hole show_test Parameter für die Anzeige @@ -3672,8 +3686,8 @@ def add_resources(): return render_template('add_resources.html', show_test=show_test) -@app.route('/resources/quarantine/', methods=['POST']) -@login_required +# @app.route('/resources/quarantine/', methods=['POST']) +# @login_required def quarantine_resource(resource_id): """Ressource in Quarantäne setzen""" reason = request.form.get('reason', 'review') @@ -3730,8 +3744,8 @@ def quarantine_resource(resource_id): status=request.args.get('status', request.form.get('status', '')), search=request.args.get('search', request.form.get('search', '')))) -@app.route('/resources/release', methods=['POST']) -@login_required +# @app.route('/resources/release', methods=['POST']) +# @login_required def release_resources(): """Ressourcen aus Quarantäne freigeben""" resource_ids = request.form.getlist('resource_ids') @@ -3781,8 +3795,8 @@ def release_resources(): status=request.args.get('status', request.form.get('status', '')), search=request.args.get('search', request.form.get('search', '')))) -@app.route('/api/resources/allocate', methods=['POST']) -@login_required +# @app.route('/api/resources/allocate', methods=['POST']) +# @login_required def allocate_resources_api(): """API für Ressourcen-Zuweisung bei Lizenzerstellung""" data = request.json @@ -3929,8 +3943,8 @@ def allocate_resources_api(): 'error': str(e) }), 400 -@app.route('/api/resources/check-availability', methods=['GET']) -@login_required +# @app.route('/api/resources/check-availability', methods=['GET']) +# @login_required def check_resource_availability(): """Prüft verfügbare Ressourcen""" resource_type = request.args.get('type', '') @@ -3988,8 +4002,8 @@ def check_resource_availability(): return jsonify(availability) -@app.route('/api/global-search', methods=['GET']) -@login_required +# @app.route('/api/global-search', methods=['GET']) +# @login_required def global_search(): """Global search API endpoint for searching customers and licenses""" query = request.args.get('q', '').strip() @@ -4051,8 +4065,8 @@ def global_search(): 'licenses': licenses }) -@app.route('/resources/history/') -@login_required +# @app.route('/resources/history/') +# @login_required def resource_history(resource_id): """Zeigt die komplette Historie einer Ressource""" conn = get_connection() @@ -4138,8 +4152,8 @@ def resource_history(resource_id): license_info=license_info, history=history_objs) -@app.route('/resources/metrics') -@login_required +# @app.route('/resources/metrics') +# @login_required def resources_metrics(): """Dashboard für Resource Metrics und Reports""" conn = get_connection() @@ -4302,8 +4316,8 @@ def resources_metrics(): problem_resources=problem_resources, daily_metrics=daily_metrics) -@app.route('/resources/report', methods=['GET']) -@login_required +# @app.route('/resources/report', methods=['GET']) +# @login_required def resources_report(): """Generiert Ressourcen-Reports oder zeigt Report-Formular""" # Prüfe ob Download angefordert wurde diff --git a/v2_adminpanel/app.py.backup b/v2_adminpanel/app.py.backup index 96c85f1..398d007 100644 --- a/v2_adminpanel/app.py.backup +++ b/v2_adminpanel/app.py.backup @@ -1,5032 +1,5032 @@ -import os -import psycopg2 -from psycopg2.extras import Json -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from functools import wraps -from dotenv import load_dotenv -import pandas as pd -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -import io -import subprocess -import gzip -from cryptography.fernet import Fernet -from pathlib import Path -import time -from apscheduler.schedulers.background import BackgroundScheduler -import logging -import random -import hashlib -import requests -import secrets -import string -import re -import bcrypt -import pyotp -import qrcode -from io import BytesIO -import base64 -import json -from werkzeug.middleware.proxy_fix import ProxyFix -from openpyxl.utils import get_column_letter - -load_dotenv() - -app = Flask(__name__) -app.config['SECRET_KEY'] = os.urandom(24) -app.config['SESSION_TYPE'] = 'filesystem' -app.config['JSON_AS_ASCII'] = False # JSON-Ausgabe mit UTF-8 -app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8' -app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=5) # 5 Minuten Session-Timeout -app.config['SESSION_COOKIE_HTTPONLY'] = True -app.config['SESSION_COOKIE_SECURE'] = False # Wird auf True gesetzt wenn HTTPS (intern läuft HTTP) -app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' -app.config['SESSION_COOKIE_NAME'] = 'admin_session' -# WICHTIG: Session-Cookie soll auch nach 5 Minuten ablaufen -app.config['SESSION_REFRESH_EACH_REQUEST'] = False -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Backup-Konfiguration -BACKUP_DIR = Path("/app/backups") -BACKUP_DIR.mkdir(exist_ok=True) - -# Rate-Limiting Konfiguration -FAIL_MESSAGES = [ - "NOPE!", - "ACCESS DENIED, TRY HARDER", - "WRONG! 🚫", - "COMPUTER SAYS NO", - "YOU FAILED" -] - -MAX_LOGIN_ATTEMPTS = 5 -BLOCK_DURATION_HOURS = 24 -CAPTCHA_AFTER_ATTEMPTS = 2 - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - - -# Login decorator -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if 'logged_in' not in session: - return redirect(url_for('login')) - - # Prüfe ob Session abgelaufen ist - if 'last_activity' in session: - last_activity = datetime.fromisoformat(session['last_activity']) - time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity - - # Debug-Logging - app.logger.info(f"Session check for {session.get('username', 'unknown')}: " - f"Last activity: {last_activity}, " - f"Time since: {time_since_activity.total_seconds()} seconds") - - if time_since_activity > timedelta(minutes=5): - # Session abgelaufen - Logout - username = session.get('username', 'unbekannt') - app.logger.info(f"Session timeout for user {username} - auto logout") - # Audit-Log für automatischen Logout (vor session.clear()!) - try: - log_audit('AUTO_LOGOUT', 'session', additional_info={'reason': 'Session timeout (5 minutes)', 'username': username}) - except: - pass - session.clear() - flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning') - return redirect(url_for('login')) - - # Aktivität NICHT automatisch aktualisieren - # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) - return f(*args, **kwargs) - return decorated_function - -# DB-Verbindung mit UTF-8 Encoding -def get_connection(): - conn = psycopg2.connect( - host=os.getenv("POSTGRES_HOST", "postgres"), - port=os.getenv("POSTGRES_PORT", "5432"), - dbname=os.getenv("POSTGRES_DB"), - user=os.getenv("POSTGRES_USER"), - password=os.getenv("POSTGRES_PASSWORD"), - options='-c client_encoding=UTF8' - ) - conn.set_client_encoding('UTF8') - return conn - -# User Authentication Helper Functions -def hash_password(password): - """Hash a password using bcrypt""" - return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - -def verify_password(password, hashed): - """Verify a password against its hash""" - return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) - -def get_user_by_username(username): - """Get user from database by username""" - conn = get_connection() - cur = conn.cursor() - try: - cur.execute(""" - SELECT id, username, password_hash, email, totp_secret, totp_enabled, - backup_codes, last_password_change, failed_2fa_attempts - FROM users WHERE username = %s - """, (username,)) - user = cur.fetchone() - if user: - return { - 'id': user[0], - 'username': user[1], - 'password_hash': user[2], - 'email': user[3], - 'totp_secret': user[4], - 'totp_enabled': user[5], - 'backup_codes': user[6], - 'last_password_change': user[7], - 'failed_2fa_attempts': user[8] - } - return None - finally: - cur.close() - conn.close() - -def generate_totp_secret(): - """Generate a new TOTP secret""" - return pyotp.random_base32() - -def generate_qr_code(username, totp_secret): - """Generate QR code for TOTP setup""" - totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( - name=username, - issuer_name='V2 Admin Panel' - ) - - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(totp_uri) - qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - buf = BytesIO() - img.save(buf, format='PNG') - buf.seek(0) - - return base64.b64encode(buf.getvalue()).decode() - -def verify_totp(totp_secret, token): - """Verify a TOTP token""" - totp = pyotp.TOTP(totp_secret) - return totp.verify(token, valid_window=1) - -def generate_backup_codes(count=8): - """Generate backup codes for 2FA recovery""" - codes = [] - for _ in range(count): - code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) - codes.append(code) - return codes - -def hash_backup_code(code): - """Hash a backup code for storage""" - return hashlib.sha256(code.encode()).hexdigest() - -def verify_backup_code(code, hashed_codes): - """Verify a backup code against stored hashes""" - code_hash = hashlib.sha256(code.encode()).hexdigest() - return code_hash in hashed_codes - -# Audit-Log-Funktion -def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): - """Protokolliert Änderungen im Audit-Log""" - conn = get_connection() - cur = conn.cursor() - - try: - username = session.get('username', 'system') - ip_address = get_client_ip() if request else None - user_agent = request.headers.get('User-Agent') if request else None - - # Debug logging - app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") - - # Konvertiere Dictionaries zu JSONB - old_json = Json(old_values) if old_values else None - new_json = Json(new_values) if new_values else None - - cur.execute(""" - INSERT INTO audit_log - (username, action, entity_type, entity_id, old_values, new_values, - ip_address, user_agent, additional_info) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - """, (username, action, entity_type, entity_id, old_json, new_json, - ip_address, user_agent, additional_info)) - - conn.commit() - except Exception as e: - print(f"Audit log error: {e}") - conn.rollback() - finally: - 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(ZoneInfo("Europe/Berlin")).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 -) - -# Rate-Limiting Funktionen -def get_client_ip(): - """Ermittelt die echte IP-Adresse des Clients""" - # Debug logging - app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") - - # Try X-Real-IP first (set by nginx) - if request.headers.get('X-Real-IP'): - return request.headers.get('X-Real-IP') - # Then X-Forwarded-For - elif request.headers.get('X-Forwarded-For'): - # X-Forwarded-For can contain multiple IPs, take the first one - return request.headers.get('X-Forwarded-For').split(',')[0].strip() - # Fallback to remote_addr - else: - return request.remote_addr - -def check_ip_blocked(ip_address): - """Prüft ob eine IP-Adresse gesperrt ist""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT blocked_until FROM login_attempts - WHERE ip_address = %s AND blocked_until IS NOT NULL - """, (ip_address,)) - - result = cur.fetchone() - cur.close() - conn.close() - - if result and result[0]: - if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None): - return True, result[0] - return False, None - -def record_failed_attempt(ip_address, username): - """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" - conn = get_connection() - cur = conn.cursor() - - # Random Fehlermeldung - error_message = random.choice(FAIL_MESSAGES) - - try: - # Prüfen ob IP bereits existiert - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - - if result: - # Update bestehenden Eintrag - new_count = result[0] + 1 - blocked_until = None - - if new_count >= MAX_LOGIN_ATTEMPTS: - blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS) - # E-Mail-Benachrichtigung (wenn aktiviert) - if os.getenv("EMAIL_ENABLED", "false").lower() == "true": - send_security_alert_email(ip_address, username, new_count) - - cur.execute(""" - UPDATE login_attempts - SET attempt_count = %s, - last_attempt = CURRENT_TIMESTAMP, - blocked_until = %s, - last_username_tried = %s, - last_error_message = %s - WHERE ip_address = %s - """, (new_count, blocked_until, username, error_message, ip_address)) - else: - # Neuen Eintrag erstellen - cur.execute(""" - INSERT INTO login_attempts - (ip_address, attempt_count, last_username_tried, last_error_message) - VALUES (%s, 1, %s, %s) - """, (ip_address, username, error_message)) - - conn.commit() - - # Audit-Log - log_audit('LOGIN_FAILED', 'user', - additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}") - - except Exception as e: - print(f"Rate limiting error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - - return error_message - -def reset_login_attempts(ip_address): - """Setzt die Login-Versuche für eine IP zurück""" - conn = get_connection() - cur = conn.cursor() - - try: - cur.execute(""" - DELETE FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - conn.commit() - except Exception as e: - print(f"Reset attempts error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - -def get_login_attempts(ip_address): - """Gibt die Anzahl der Login-Versuche für eine IP zurück""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - cur.close() - conn.close() - - return result[0] if result else 0 - -def send_security_alert_email(ip_address, username, attempt_count): - """Sendet eine Sicherheitswarnung per E-Mail""" - subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche" - body = f""" - WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt! - - IP-Adresse: {ip_address} - Versuchter Benutzername: {username} - Anzahl Versuche: {attempt_count} - Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')} - - Die IP-Adresse wurde für 24 Stunden gesperrt. - - Dies ist eine automatische Nachricht vom v2-Docker Admin Panel. - """ - - # TODO: E-Mail-Versand implementieren wenn SMTP konfiguriert - logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") - print(f"E-Mail würde gesendet: {subject}") - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = os.getenv('RECAPTCHA_SECRET_KEY') - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - -def generate_license_key(license_type='full'): - """ - Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ - - AF = Account Factory (Produktkennung) - F/T = F für Fullversion, T für Testversion - YYYY = Jahr - MM = Monat - XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen - """ - # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) - chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' - - # Datum-Teil - now = datetime.now(ZoneInfo("Europe/Berlin")) - date_part = now.strftime('%Y%m') - type_char = 'F' if license_type == 'full' else 'T' - - # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) - parts = [] - for _ in range(3): - part = ''.join(secrets.choice(chars) for _ in range(4)) - parts.append(part) - - # Key zusammensetzen - key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" - - return key - -def validate_license_key(key): - """ - Validiert das License Key Format - Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ - """ - if not key: - return False - - # Pattern für das neue Format - # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen - pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' - - # Großbuchstaben für Vergleich - return bool(re.match(pattern, key.upper())) - -@app.route("/login", methods=["GET", "POST"]) -def login(): - # Timing-Attack Schutz - Start Zeit merken - start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') - if attempt_count >= CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - if not captcha_response: - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA ERFORDERLICH!", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - user = get_user_by_username(username) - login_success = False - needs_2fa = False - - if user: - # Database user authentication - if verify_password(password, user['password_hash']): - login_success = True - needs_2fa = user['totp_enabled'] - else: - # Fallback to environment variables for backward compatibility - admin1_user = os.getenv("ADMIN1_USERNAME") - admin1_pass = os.getenv("ADMIN1_PASSWORD") - admin2_user = os.getenv("ADMIN2_USERNAME") - admin2_pass = os.getenv("ADMIN2_PASSWORD") - - if ((username == admin1_user and password == admin1_pass) or - (username == admin2_user and password == admin2_pass)): - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), - error_type="failed", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) - -@app.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('login')) - -@app.route("/verify-2fa", methods=["GET", "POST"]) -def verify_2fa(): - if not session.get('awaiting_2fa'): - return redirect(url_for('login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - conn.commit() - cur.close() - conn.close() - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('dashboard')) - - # Failed verification - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - conn.commit() - cur.close() - conn.close() - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - -@app.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -@app.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -@app.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -@app.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -@app.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -@app.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -@app.route("/api/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -@app.route("/api/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -@app.route("/") -@login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - 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() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -@app.route("/create", methods=["GET", "POST"]) -@login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -@app.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -@app.route("/batch/export") -@login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -@app.route("/licenses") -@login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/license/edit/", methods=["GET", "POST"]) -@login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -@app.route("/license/delete/", methods=["POST"]) -@login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -@app.route("/customers") -@login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/customer/edit/", methods=["GET", "POST"]) -@login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -@app.route("/customer/create", methods=["GET", "POST"]) -@login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -@app.route("/customer/delete/", methods=["POST"]) -@login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -@app.route("/customers-licenses") -@login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -@app.route("/api/customer//licenses") -@login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -@app.route("/api/customer//quick-stats") -@login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -@app.route("/api/license//quick-edit", methods=['POST']) -@login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/api/license//resources") -@login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/sessions") -@login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -@app.route("/session/end/", methods=["POST"]) -@login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -@app.route("/export/licenses") -@login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/audit") -@login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/customers") -@login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/sessions") -@login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/resources") -@login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/audit") -@login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - 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) - -@app.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -@app.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -@app.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -@app.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -@app.route("/api/license//toggle", methods=["POST"]) -@login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -@app.route("/api/license//register-device", methods=["POST"]) -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - try: - data = request.get_json() - hardware_id = data.get('hardware_id') - device_name = data.get('device_name', '') - operating_system = data.get('operating_system', '') - - if not hardware_id: - return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - cur.execute(""" - SELECT device_limit, is_active, valid_until - FROM licenses - WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - if not is_active: - return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_id = %s AND hardware_id = %s - """, (license_id, hardware_id)) - existing_device = cur.fetchone() - - if existing_device: - device_id, is_device_active = existing_device - if is_device_active: - # Gerät ist bereits aktiv, update last_seen - cur.execute(""" - UPDATE device_registrations - SET last_seen = CURRENT_TIMESTAMP, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = TRUE, - last_seen = CURRENT_TIMESTAMP, - deactivated_at = NULL, - deactivated_by = NULL, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (license_id, hardware_id, device_name, operating_system, - get_client_ip(), request.headers.get('User-Agent', ''))) - device_id = cur.fetchone()[0] - - conn.commit() - - # Audit Log - log_audit('DEVICE_REGISTER', 'device', device_id, - new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - except Exception as e: - logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -@app.route("/api/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -@app.route("/api/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -@app.route('/resources') -@login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -@app.route('/resources/add', methods=['GET', 'POST']) -@login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -@app.route('/resources/quarantine/', methods=['POST']) -@login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/resources/release', methods=['POST']) -@login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/api/resources/allocate', methods=['POST']) -@login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -@app.route('/api/resources/check-availability', methods=['GET']) -@login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -@app.route('/api/global-search', methods=['GET']) -@login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -@app.route('/resources/history/') -@login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -@app.route('/resources/metrics') -@login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -@app.route('/resources/report', methods=['GET']) -@login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) +import os +import psycopg2 +from psycopg2.extras import Json +from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash +from flask_session import Session +from functools import wraps +from dotenv import load_dotenv +import pandas as pd +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +import io +import subprocess +import gzip +from cryptography.fernet import Fernet +from pathlib import Path +import time +from apscheduler.schedulers.background import BackgroundScheduler +import logging +import random +import hashlib +import requests +import secrets +import string +import re +import bcrypt +import pyotp +import qrcode +from io import BytesIO +import base64 +import json +from werkzeug.middleware.proxy_fix import ProxyFix +from openpyxl.utils import get_column_letter + +load_dotenv() + +app = Flask(__name__) +app.config['SECRET_KEY'] = os.urandom(24) +app.config['SESSION_TYPE'] = 'filesystem' +app.config['JSON_AS_ASCII'] = False # JSON-Ausgabe mit UTF-8 +app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8' +app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=5) # 5 Minuten Session-Timeout +app.config['SESSION_COOKIE_HTTPONLY'] = True +app.config['SESSION_COOKIE_SECURE'] = False # Wird auf True gesetzt wenn HTTPS (intern läuft HTTP) +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' +app.config['SESSION_COOKIE_NAME'] = 'admin_session' +# WICHTIG: Session-Cookie soll auch nach 5 Minuten ablaufen +app.config['SESSION_REFRESH_EACH_REQUEST'] = False +Session(app) + +# ProxyFix für korrekte IP-Adressen hinter Nginx +app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 +) + +# Backup-Konfiguration +BACKUP_DIR = Path("/app/backups") +BACKUP_DIR.mkdir(exist_ok=True) + +# Rate-Limiting Konfiguration +FAIL_MESSAGES = [ + "NOPE!", + "ACCESS DENIED, TRY HARDER", + "WRONG! 🚫", + "COMPUTER SAYS NO", + "YOU FAILED" +] + +MAX_LOGIN_ATTEMPTS = 5 +BLOCK_DURATION_HOURS = 24 +CAPTCHA_AFTER_ATTEMPTS = 2 + +# Scheduler für automatische Backups +scheduler = BackgroundScheduler() +scheduler.start() + +# Logging konfigurieren +logging.basicConfig(level=logging.INFO) + + +# Login decorator +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'logged_in' not in session: + return redirect(url_for('login')) + + # Prüfe ob Session abgelaufen ist + if 'last_activity' in session: + last_activity = datetime.fromisoformat(session['last_activity']) + time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity + + # Debug-Logging + app.logger.info(f"Session check for {session.get('username', 'unknown')}: " + f"Last activity: {last_activity}, " + f"Time since: {time_since_activity.total_seconds()} seconds") + + if time_since_activity > timedelta(minutes=5): + # Session abgelaufen - Logout + username = session.get('username', 'unbekannt') + app.logger.info(f"Session timeout for user {username} - auto logout") + # Audit-Log für automatischen Logout (vor session.clear()!) + try: + log_audit('AUTO_LOGOUT', 'session', additional_info={'reason': 'Session timeout (5 minutes)', 'username': username}) + except: + pass + session.clear() + flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning') + return redirect(url_for('login')) + + # Aktivität NICHT automatisch aktualisieren + # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) + return f(*args, **kwargs) + return decorated_function + +# DB-Verbindung mit UTF-8 Encoding +def get_connection(): + conn = psycopg2.connect( + host=os.getenv("POSTGRES_HOST", "postgres"), + port=os.getenv("POSTGRES_PORT", "5432"), + dbname=os.getenv("POSTGRES_DB"), + user=os.getenv("POSTGRES_USER"), + password=os.getenv("POSTGRES_PASSWORD"), + options='-c client_encoding=UTF8' + ) + conn.set_client_encoding('UTF8') + return conn + +# User Authentication Helper Functions +def hash_password(password): + """Hash a password using bcrypt""" + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + +def verify_password(password, hashed): + """Verify a password against its hash""" + return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) + +def get_user_by_username(username): + """Get user from database by username""" + conn = get_connection() + cur = conn.cursor() + try: + cur.execute(""" + SELECT id, username, password_hash, email, totp_secret, totp_enabled, + backup_codes, last_password_change, failed_2fa_attempts + FROM users WHERE username = %s + """, (username,)) + user = cur.fetchone() + if user: + return { + 'id': user[0], + 'username': user[1], + 'password_hash': user[2], + 'email': user[3], + 'totp_secret': user[4], + 'totp_enabled': user[5], + 'backup_codes': user[6], + 'last_password_change': user[7], + 'failed_2fa_attempts': user[8] + } + return None + finally: + cur.close() + conn.close() + +def generate_totp_secret(): + """Generate a new TOTP secret""" + return pyotp.random_base32() + +def generate_qr_code(username, totp_secret): + """Generate QR code for TOTP setup""" + totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( + name=username, + issuer_name='V2 Admin Panel' + ) + + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(totp_uri) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + buf = BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + + return base64.b64encode(buf.getvalue()).decode() + +def verify_totp(totp_secret, token): + """Verify a TOTP token""" + totp = pyotp.TOTP(totp_secret) + return totp.verify(token, valid_window=1) + +def generate_backup_codes(count=8): + """Generate backup codes for 2FA recovery""" + codes = [] + for _ in range(count): + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) + codes.append(code) + return codes + +def hash_backup_code(code): + """Hash a backup code for storage""" + return hashlib.sha256(code.encode()).hexdigest() + +def verify_backup_code(code, hashed_codes): + """Verify a backup code against stored hashes""" + code_hash = hashlib.sha256(code.encode()).hexdigest() + return code_hash in hashed_codes + +# Audit-Log-Funktion +def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): + """Protokolliert Änderungen im Audit-Log""" + conn = get_connection() + cur = conn.cursor() + + try: + username = session.get('username', 'system') + ip_address = get_client_ip() if request else None + user_agent = request.headers.get('User-Agent') if request else None + + # Debug logging + app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") + + # Konvertiere Dictionaries zu JSONB + old_json = Json(old_values) if old_values else None + new_json = Json(new_values) if new_values else None + + cur.execute(""" + INSERT INTO audit_log + (username, action, entity_type, entity_id, old_values, new_values, + ip_address, user_agent, additional_info) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (username, action, entity_type, entity_id, old_json, new_json, + ip_address, user_agent, additional_info)) + + conn.commit() + except Exception as e: + print(f"Audit log error: {e}") + conn.rollback() + finally: + 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(ZoneInfo("Europe/Berlin")).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 +) + +# Rate-Limiting Funktionen +def get_client_ip(): + """Ermittelt die echte IP-Adresse des Clients""" + # Debug logging + app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") + + # Try X-Real-IP first (set by nginx) + if request.headers.get('X-Real-IP'): + return request.headers.get('X-Real-IP') + # Then X-Forwarded-For + elif request.headers.get('X-Forwarded-For'): + # X-Forwarded-For can contain multiple IPs, take the first one + return request.headers.get('X-Forwarded-For').split(',')[0].strip() + # Fallback to remote_addr + else: + return request.remote_addr + +def check_ip_blocked(ip_address): + """Prüft ob eine IP-Adresse gesperrt ist""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT blocked_until FROM login_attempts + WHERE ip_address = %s AND blocked_until IS NOT NULL + """, (ip_address,)) + + result = cur.fetchone() + cur.close() + conn.close() + + if result and result[0]: + if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None): + return True, result[0] + return False, None + +def record_failed_attempt(ip_address, username): + """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" + conn = get_connection() + cur = conn.cursor() + + # Random Fehlermeldung + error_message = random.choice(FAIL_MESSAGES) + + try: + # Prüfen ob IP bereits existiert + cur.execute(""" + SELECT attempt_count FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + + result = cur.fetchone() + + if result: + # Update bestehenden Eintrag + new_count = result[0] + 1 + blocked_until = None + + if new_count >= MAX_LOGIN_ATTEMPTS: + blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS) + # E-Mail-Benachrichtigung (wenn aktiviert) + if os.getenv("EMAIL_ENABLED", "false").lower() == "true": + send_security_alert_email(ip_address, username, new_count) + + cur.execute(""" + UPDATE login_attempts + SET attempt_count = %s, + last_attempt = CURRENT_TIMESTAMP, + blocked_until = %s, + last_username_tried = %s, + last_error_message = %s + WHERE ip_address = %s + """, (new_count, blocked_until, username, error_message, ip_address)) + else: + # Neuen Eintrag erstellen + cur.execute(""" + INSERT INTO login_attempts + (ip_address, attempt_count, last_username_tried, last_error_message) + VALUES (%s, 1, %s, %s) + """, (ip_address, username, error_message)) + + conn.commit() + + # Audit-Log + log_audit('LOGIN_FAILED', 'user', + additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}") + + except Exception as e: + print(f"Rate limiting error: {e}") + conn.rollback() + finally: + cur.close() + conn.close() + + return error_message + +def reset_login_attempts(ip_address): + """Setzt die Login-Versuche für eine IP zurück""" + conn = get_connection() + cur = conn.cursor() + + try: + cur.execute(""" + DELETE FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + conn.commit() + except Exception as e: + print(f"Reset attempts error: {e}") + conn.rollback() + finally: + cur.close() + conn.close() + +def get_login_attempts(ip_address): + """Gibt die Anzahl der Login-Versuche für eine IP zurück""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT attempt_count FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + + result = cur.fetchone() + cur.close() + conn.close() + + return result[0] if result else 0 + +def send_security_alert_email(ip_address, username, attempt_count): + """Sendet eine Sicherheitswarnung per E-Mail""" + subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche" + body = f""" + WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt! + + IP-Adresse: {ip_address} + Versuchter Benutzername: {username} + Anzahl Versuche: {attempt_count} + Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')} + + Die IP-Adresse wurde für 24 Stunden gesperrt. + + Dies ist eine automatische Nachricht vom v2-Docker Admin Panel. + """ + + # TODO: E-Mail-Versand implementieren wenn SMTP konfiguriert + logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") + print(f"E-Mail würde gesendet: {subject}") + +def verify_recaptcha(response): + """Verifiziert die reCAPTCHA v2 Response mit Google""" + secret_key = os.getenv('RECAPTCHA_SECRET_KEY') + + # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) + if not secret_key: + logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") + return True + + # Verifizierung bei Google + try: + verify_url = 'https://www.google.com/recaptcha/api/siteverify' + data = { + 'secret': secret_key, + 'response': response + } + + # Timeout für Request setzen + r = requests.post(verify_url, data=data, timeout=5) + result = r.json() + + # Log für Debugging + if not result.get('success'): + logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") + + return result.get('success', False) + + except requests.exceptions.RequestException as e: + logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") + # Bei Netzwerkfehlern CAPTCHA als bestanden werten + return True + except Exception as e: + logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") + return False + +def generate_license_key(license_type='full'): + """ + Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ + + AF = Account Factory (Produktkennung) + F/T = F für Fullversion, T für Testversion + YYYY = Jahr + MM = Monat + XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen + """ + # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) + chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + + # Datum-Teil + now = datetime.now(ZoneInfo("Europe/Berlin")) + date_part = now.strftime('%Y%m') + type_char = 'F' if license_type == 'full' else 'T' + + # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) + parts = [] + for _ in range(3): + part = ''.join(secrets.choice(chars) for _ in range(4)) + parts.append(part) + + # Key zusammensetzen + key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" + + return key + +def validate_license_key(key): + """ + Validiert das License Key Format + Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ + """ + if not key: + return False + + # Pattern für das neue Format + # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen + pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' + + # Großbuchstaben für Vergleich + return bool(re.match(pattern, key.upper())) + +@app.route("/login", methods=["GET", "POST"]) +def login(): + # Timing-Attack Schutz - Start Zeit merken + start_time = time.time() + + # IP-Adresse ermitteln + ip_address = get_client_ip() + + # Prüfen ob IP gesperrt ist + is_blocked, blocked_until = check_ip_blocked(ip_address) + if is_blocked: + time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 + error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." + return render_template("login.html", error=error_msg, error_type="blocked") + + # Anzahl bisheriger Versuche + attempt_count = get_login_attempts(ip_address) + + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + captcha_response = request.form.get("g-recaptcha-response") + + # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind + recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') + if attempt_count >= CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: + if not captcha_response: + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA ERFORDERLICH!", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # CAPTCHA validieren + if not verify_recaptcha(captcha_response): + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # Check user in database first, fallback to env vars + user = get_user_by_username(username) + login_success = False + needs_2fa = False + + if user: + # Database user authentication + if verify_password(password, user['password_hash']): + login_success = True + needs_2fa = user['totp_enabled'] + else: + # Fallback to environment variables for backward compatibility + admin1_user = os.getenv("ADMIN1_USERNAME") + admin1_pass = os.getenv("ADMIN1_PASSWORD") + admin2_user = os.getenv("ADMIN2_USERNAME") + admin2_pass = os.getenv("ADMIN2_PASSWORD") + + if ((username == admin1_user and password == admin1_pass) or + (username == admin2_user and password == admin2_pass)): + login_success = True + + # Timing-Attack Schutz - Mindestens 1 Sekunde warten + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + + if login_success: + # Erfolgreicher Login + if needs_2fa: + # Store temporary session for 2FA verification + session['temp_username'] = username + session['temp_user_id'] = user['id'] + session['awaiting_2fa'] = True + return redirect(url_for('verify_2fa')) + else: + # Complete login without 2FA + session.permanent = True # Aktiviert das Timeout + session['logged_in'] = True + session['username'] = username + session['user_id'] = user['id'] if user else None + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + reset_login_attempts(ip_address) + log_audit('LOGIN_SUCCESS', 'user', + additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") + return redirect(url_for('dashboard')) + else: + # Fehlgeschlagener Login + error_message = record_failed_attempt(ip_address, username) + new_attempt_count = get_login_attempts(ip_address) + + # Prüfen ob jetzt gesperrt + is_now_blocked, _ = check_ip_blocked(ip_address) + if is_now_blocked: + log_audit('LOGIN_BLOCKED', 'security', + additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") + + return render_template("login.html", + error=error_message, + show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), + error_type="failed", + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), + recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) + + # GET Request + return render_template("login.html", + show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) + +@app.route("/logout") +def logout(): + username = session.get('username', 'unknown') + log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") + session.pop('logged_in', None) + session.pop('username', None) + session.pop('user_id', None) + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + return redirect(url_for('login')) + +@app.route("/verify-2fa", methods=["GET", "POST"]) +def verify_2fa(): + if not session.get('awaiting_2fa'): + return redirect(url_for('login')) + + if request.method == "POST": + token = request.form.get('token', '').replace(' ', '') + username = session.get('temp_username') + user_id = session.get('temp_user_id') + + if not username or not user_id: + flash('Session expired. Please login again.', 'error') + return redirect(url_for('login')) + + user = get_user_by_username(username) + if not user: + flash('User not found.', 'error') + return redirect(url_for('login')) + + # Check if it's a backup code + if len(token) == 8 and token.isupper(): + # Try backup code + backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] + if verify_backup_code(token, backup_codes): + # Remove used backup code + code_hash = hash_backup_code(token) + backup_codes.remove(code_hash) + + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", + (json.dumps(backup_codes), user_id)) + conn.commit() + cur.close() + conn.close() + + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + flash('Login successful using backup code. Please generate new backup codes.', 'warning') + log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") + return redirect(url_for('dashboard')) + else: + # Try TOTP token + if verify_totp(user['totp_secret'], token): + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") + return redirect(url_for('dashboard')) + + # Failed verification + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", + (datetime.now(), user_id)) + conn.commit() + cur.close() + conn.close() + + flash('Invalid authentication code. Please try again.', 'error') + log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") + + return render_template('verify_2fa.html') + +@app.route("/profile") +@login_required +def profile(): + user = get_user_by_username(session['username']) + if not user: + # For environment-based users, redirect with message + flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') + return redirect(url_for('dashboard')) + return render_template('profile.html', user=user) + +@app.route("/profile/change-password", methods=["POST"]) +@login_required +def change_password(): + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + user = get_user_by_username(session['username']) + + # Verify current password + if not verify_password(current_password, user['password_hash']): + flash('Current password is incorrect.', 'error') + return redirect(url_for('profile')) + + # Check new password + if new_password != confirm_password: + flash('New passwords do not match.', 'error') + return redirect(url_for('profile')) + + if len(new_password) < 8: + flash('Password must be at least 8 characters long.', 'error') + return redirect(url_for('profile')) + + # Update password + new_hash = hash_password(new_password) + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", + (new_hash, datetime.now(), user['id'])) + conn.commit() + cur.close() + conn.close() + + log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], + additional_info="Password changed successfully") + flash('Password changed successfully.', 'success') + return redirect(url_for('profile')) + +@app.route("/profile/setup-2fa") +@login_required +def setup_2fa(): + user = get_user_by_username(session['username']) + + if user['totp_enabled']: + flash('2FA is already enabled for your account.', 'info') + return redirect(url_for('profile')) + + # Generate new TOTP secret + totp_secret = generate_totp_secret() + session['temp_totp_secret'] = totp_secret + + # Generate QR code + qr_code = generate_qr_code(user['username'], totp_secret) + + return render_template('setup_2fa.html', + totp_secret=totp_secret, + qr_code=qr_code) + +@app.route("/profile/enable-2fa", methods=["POST"]) +@login_required +def enable_2fa(): + token = request.form.get('token', '').replace(' ', '') + totp_secret = session.get('temp_totp_secret') + + if not totp_secret: + flash('2FA setup session expired. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Verify the token + if not verify_totp(totp_secret, token): + flash('Invalid authentication code. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Generate backup codes + backup_codes = generate_backup_codes() + hashed_codes = [hash_backup_code(code) for code in backup_codes] + + # Enable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s + WHERE username = %s + """, (totp_secret, json.dumps(hashed_codes), session['username'])) + conn.commit() + cur.close() + conn.close() + + session.pop('temp_totp_secret', None) + + log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") + + # Show backup codes + return render_template('backup_codes.html', backup_codes=backup_codes) + +@app.route("/profile/disable-2fa", methods=["POST"]) +@login_required +def disable_2fa(): + password = request.form.get('password') + user = get_user_by_username(session['username']) + + # Verify password + if not verify_password(password, user['password_hash']): + flash('Incorrect password.', 'error') + return redirect(url_for('profile')) + + # Disable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL + WHERE username = %s + """, (session['username'],)) + conn.commit() + cur.close() + conn.close() + + log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") + flash('2FA has been disabled for your account.', 'success') + return redirect(url_for('profile')) + +@app.route("/heartbeat", methods=['POST']) +@login_required +def heartbeat(): + """Endpoint für Session Keep-Alive - aktualisiert last_activity""" + # Aktualisiere last_activity nur wenn explizit angefordert + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + # Force session save + session.modified = True + + return jsonify({ + 'status': 'ok', + 'last_activity': session['last_activity'], + 'username': session.get('username') + }) + +@app.route("/api/generate-license-key", methods=['POST']) +@login_required +def api_generate_key(): + """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" + try: + # Lizenztyp aus Request holen (default: full) + data = request.get_json() or {} + license_type = data.get('type', 'full') + + # Key generieren + key = generate_license_key(license_type) + + # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) + conn = get_connection() + cur = conn.cursor() + + # Wiederhole bis eindeutiger Key gefunden + attempts = 0 + while attempts < 10: # Max 10 Versuche + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) + if not cur.fetchone(): + break # Key ist eindeutig + key = generate_license_key(license_type) + attempts += 1 + + cur.close() + conn.close() + + # Log für Audit + log_audit('GENERATE_KEY', 'license', + additional_info={'type': license_type, 'key': key}) + + return jsonify({ + 'success': True, + 'key': key, + 'type': license_type + }) + + except Exception as e: + logging.error(f"Fehler bei Key-Generierung: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Fehler bei der Key-Generierung' + }), 500 + +@app.route("/api/customers", methods=['GET']) +@login_required +def api_customers(): + """API Endpoint für die Kundensuche mit Select2""" + try: + # Suchparameter + search = request.args.get('q', '').strip() + page = request.args.get('page', 1, type=int) + per_page = 20 + customer_id = request.args.get('id', type=int) + + conn = get_connection() + cur = conn.cursor() + + # Einzelnen Kunden per ID abrufen + if customer_id: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.id = %s + GROUP BY c.id, c.name, c.email + """, (customer_id,)) + + customer = cur.fetchone() + results = [] + if customer: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} ({customer[2]})", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + cur.close() + conn.close() + + return jsonify({ + 'results': results, + 'pagination': {'more': False} + }) + + # SQL Query mit optionaler Suche + elif search: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE LOWER(c.name) LIKE LOWER(%s) + OR LOWER(c.email) LIKE LOWER(%s) + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) + else: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (per_page, (page - 1) * per_page)) + + customers = cur.fetchall() + + # Format für Select2 + results = [] + for customer in customers: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + # Gesamtanzahl für Pagination + if search: + cur.execute(""" + SELECT COUNT(*) FROM customers + WHERE LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + """, (f'%{search}%', f'%{search}%')) + else: + cur.execute("SELECT COUNT(*) FROM customers") + + total_count = cur.fetchone()[0] + + cur.close() + conn.close() + + # Select2 Response Format + return jsonify({ + 'results': results, + 'pagination': { + 'more': (page * per_page) < total_count + } + }) + + except Exception as e: + logging.error(f"Fehler bei Kundensuche: {str(e)}") + return jsonify({ + 'results': [], + 'error': 'Fehler bei der Kundensuche' + }), 500 + +@app.route("/") +@login_required +def dashboard(): + conn = get_connection() + cur = conn.cursor() + + # Statistiken abrufen + # Gesamtanzahl Kunden (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") + total_customers = cur.fetchone()[0] + + # Gesamtanzahl Lizenzen (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") + total_licenses = cur.fetchone()[0] + + # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE + """) + active_licenses = cur.fetchone()[0] + + # Aktive Sessions + cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") + active_sessions_count = cur.fetchone()[0] + + # Abgelaufene Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until < CURRENT_DATE AND is_test = FALSE + """) + expired_licenses = cur.fetchone()[0] + + # Deaktivierte Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE is_active = FALSE AND is_test = FALSE + """) + inactive_licenses = cur.fetchone()[0] + + # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE + AND valid_until < CURRENT_DATE + INTERVAL '30 days' + AND is_active = TRUE + AND is_test = FALSE + """) + expiring_soon = cur.fetchone()[0] + + # Testlizenzen vs Vollversionen (ohne Testdaten) + cur.execute(""" + SELECT license_type, COUNT(*) + FROM licenses + WHERE is_test = FALSE + GROUP BY license_type + """) + license_types = dict(cur.fetchall()) + + # Anzahl Testdaten + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") + test_data_count = cur.fetchone()[0] + + # Anzahl Test-Kunden + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") + test_customers_count = cur.fetchone()[0] + + # Anzahl Test-Ressourcen + cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") + test_resources_count = cur.fetchone()[0] + + # Letzte 5 erstellten Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.is_test = FALSE + ORDER BY l.id DESC + LIMIT 5 + """) + recent_licenses = cur.fetchall() + + # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + l.valid_until - CURRENT_DATE as days_left + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.valid_until >= CURRENT_DATE + AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' + AND l.is_active = TRUE + AND l.is_test = FALSE + ORDER BY l.valid_until + LIMIT 10 + """) + 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() + + # Sicherheitsstatistiken + # Gesperrte IPs + cur.execute(""" + SELECT COUNT(*) FROM login_attempts + WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP + """) + blocked_ips_count = cur.fetchone()[0] + + # Fehlversuche heute + cur.execute(""" + SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts + WHERE last_attempt::date = CURRENT_DATE + """) + failed_attempts_today = cur.fetchone()[0] + + # Letzte 5 Sicherheitsereignisse + cur.execute(""" + SELECT + la.ip_address, + la.attempt_count, + la.last_attempt, + la.blocked_until, + la.last_username_tried, + la.last_error_message + FROM login_attempts la + ORDER BY la.last_attempt DESC + LIMIT 5 + """) + recent_security_events = [] + for event in cur.fetchall(): + recent_security_events.append({ + 'ip_address': event[0], + 'attempt_count': event[1], + 'last_attempt': event[2].strftime('%d.%m %H:%M'), + 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, + 'username_tried': event[4], + 'error_message': event[5] + }) + + # Sicherheitslevel berechnen + if blocked_ips_count > 5 or failed_attempts_today > 50: + security_level = 'danger' + security_level_text = 'KRITISCH' + elif blocked_ips_count > 2 or failed_attempts_today > 20: + security_level = 'warning' + security_level_text = 'ERHÖHT' + else: + security_level = 'success' + security_level_text = 'NORMAL' + + # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = FALSE + GROUP BY resource_type + """) + + resource_stats = {} + resource_warning = None + + for row in cur.fetchall(): + available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + resource_stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': available_percent, + 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' + } + + # Warnung bei niedrigem Bestand + if row[1] < 50: + if not resource_warning: + resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" + else: + resource_warning += f" | {row[0].upper()}: {row[1]}" + + cur.close() + conn.close() + + stats = { + 'total_customers': total_customers, + 'total_licenses': total_licenses, + 'active_licenses': active_licenses, + 'expired_licenses': expired_licenses, + 'inactive_licenses': inactive_licenses, + 'expiring_soon': expiring_soon, + 'full_licenses': license_types.get('full', 0), + 'test_licenses': license_types.get('test', 0), + 'test_data_count': test_data_count, + 'test_customers_count': test_customers_count, + 'test_resources_count': test_resources_count, + 'recent_licenses': recent_licenses, + 'expiring_licenses': expiring_licenses, + 'active_sessions': active_sessions_count, + 'last_backup': last_backup_info, + # Sicherheitsstatistiken + 'blocked_ips_count': blocked_ips_count, + 'failed_attempts_today': failed_attempts_today, + 'recent_security_events': recent_security_events, + 'security_level': security_level, + 'security_level_text': security_level_text, + 'resource_stats': resource_stats + } + + return render_template("dashboard.html", + stats=stats, + resource_stats=resource_stats, + resource_warning=resource_warning, + username=session.get('username')) + +@app.route("/create", methods=["GET", "POST"]) +@login_required +def create_license(): + if request.method == "POST": + customer_id = request.form.get("customer_id") + license_key = request.form["license_key"].upper() # Immer Großbuchstaben + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Validiere License Key Format + if not validate_license_key(license_key): + flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') + return redirect(url_for('create_license')) + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('create_license')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('create_license')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + customer_info = {'name': name, 'email': email, 'is_test': is_test} + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos für Audit-Log + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('create_license')) + customer_info = {'name': customer_data[0], 'email': customer_data[1]} + + # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Lizenz hinzufügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit, is_test) + VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit, is_test)) + license_id = cur.fetchone()[0] + + # Ressourcen zuweisen + try: + # Prüfe Verfügbarkeit + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") + if available[1] < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") + if available[2] < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") + + # Domains zuweisen + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s zuweisen + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern zuweisen + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + except ValueError as e: + conn.rollback() + flash(str(e), 'error') + return redirect(url_for('create_license')) + + conn.commit() + + # Audit-Log + log_audit('CREATE', 'license', license_id, + new_values={ + 'license_key': license_key, + 'customer_name': customer_info['name'], + 'customer_email': customer_info['email'], + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'device_limit': device_limit, + 'is_test': is_test + }) + + flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') + + except Exception as e: + conn.rollback() + logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") + flash('Fehler beim Erstellen der Lizenz!', 'error') + finally: + cur.close() + conn.close() + + # Preserve show_test parameter if present + redirect_url = "/create" + if request.args.get('show_test') == 'true': + redirect_url += "?show_test=true" + return redirect(redirect_url) + + # Unterstützung für vorausgewählten Kunden + preselected_customer_id = request.args.get('customer_id', type=int) + return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) + +@app.route("/batch", methods=["GET", "POST"]) +@login_required +def batch_licenses(): + """Batch-Generierung mehrerer Lizenzen für einen Kunden""" + if request.method == "POST": + # Formulardaten + customer_id = request.form.get("customer_id") + license_type = request.form["license_type"] + quantity = int(request.form["quantity"]) + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + # Sicherheitslimit + if quantity < 1 or quantity > 100: + flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') + return redirect(url_for('batch_licenses')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('batch_licenses')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('batch_licenses')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('batch_licenses')) + name = customer_data[0] + email = customer_data[1] + + # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch + total_domains_needed = domain_count * quantity + total_ipv4s_needed = ipv4_count * quantity + total_phones_needed = phone_count * quantity + + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < total_domains_needed: + flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') + return redirect(url_for('batch_licenses')) + if available[1] < total_ipv4s_needed: + flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') + return redirect(url_for('batch_licenses')) + if available[2] < total_phones_needed: + flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') + return redirect(url_for('batch_licenses')) + + # Lizenzen generieren und speichern + generated_licenses = [] + for i in range(quantity): + # Eindeutigen Key generieren + attempts = 0 + while attempts < 10: + license_key = generate_license_key(license_type) + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) + if not cur.fetchone(): + break + attempts += 1 + + # Lizenz einfügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, is_test, + valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit) + VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit)) + license_id = cur.fetchone()[0] + + # Ressourcen für diese Lizenz zuweisen + # Domains + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + generated_licenses.append({ + 'id': license_id, + 'key': license_key, + 'type': license_type + }) + + conn.commit() + + # Audit-Log + log_audit('CREATE_BATCH', 'license', + new_values={'customer': name, 'quantity': quantity, 'type': license_type}, + additional_info=f"Batch-Generierung von {quantity} Lizenzen") + + # Session für Export speichern + session['batch_export'] = { + 'customer': name, + 'email': email, + 'licenses': generated_licenses, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + } + + flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') + return render_template("batch_result.html", + customer=name, + email=email, + licenses=generated_licenses, + valid_from=valid_from, + valid_until=valid_until) + + except Exception as e: + conn.rollback() + logging.error(f"Fehler bei Batch-Generierung: {str(e)}") + flash('Fehler bei der Batch-Generierung!', 'error') + return redirect(url_for('batch_licenses')) + finally: + cur.close() + conn.close() + + # GET Request + return render_template("batch_form.html") + +@app.route("/batch/export") +@login_required +def export_batch(): + """Exportiert die zuletzt generierten Batch-Lizenzen""" + batch_data = session.get('batch_export') + if not batch_data: + flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') + return redirect(url_for('batch_licenses')) + + # CSV generieren + output = io.StringIO() + output.write('\ufeff') # UTF-8 BOM für Excel + + # Header + output.write(f"Kunde: {batch_data['customer']}\n") + output.write(f"E-Mail: {batch_data['email']}\n") + output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") + output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") + output.write("\n") + output.write("Nr;Lizenzschlüssel;Typ\n") + + # Lizenzen + for i, license in enumerate(batch_data['licenses'], 1): + typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" + output.write(f"{i};{license['key']};{typ_text}\n") + + output.seek(0) + + # Audit-Log + log_audit('EXPORT', 'batch_licenses', + additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" + ) + +@app.route("/licenses") +@login_required +def licenses(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/license/edit/", methods=["GET", "POST"]) +@login_required +def edit_license(license_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute(""" + SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit + FROM licenses WHERE id = %s + """, (license_id,)) + old_license = cur.fetchone() + + # Update license + license_key = request.form["license_key"] + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + valid_until = request.form["valid_until"] + is_active = request.form.get("is_active") == "on" + is_test = request.form.get("is_test") == "on" + device_limit = int(request.form.get("device_limit", 3)) + + cur.execute(""" + UPDATE licenses + SET license_key = %s, license_type = %s, valid_from = %s, + valid_until = %s, is_active = %s, is_test = %s, device_limit = %s + WHERE id = %s + """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'license_key': old_license[0], + 'license_type': old_license[1], + 'valid_from': str(old_license[2]), + 'valid_until': str(old_license[3]), + 'is_active': old_license[4], + 'is_test': old_license[5], + 'device_limit': old_license[6] + }, + new_values={ + 'license_key': license_key, + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'is_active': is_active, + 'is_test': is_test, + 'device_limit': device_limit + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei wenn vorhanden + if request.referrer and 'customer_id=' in request.referrer: + import re + match = re.search(r'customer_id=(\d+)', request.referrer) + if match: + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={match.group(1)}" + + return redirect(redirect_url) + + # Get license data + cur.execute(""" + SELECT l.id, l.license_key, c.name, c.email, l.license_type, + l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + + license = cur.fetchone() + cur.close() + conn.close() + + if not license: + return redirect("/licenses") + + return render_template("edit_license.html", license=license, username=session.get('username')) + +@app.route("/license/delete/", methods=["POST"]) +@login_required +def delete_license(license_id): + conn = get_connection() + cur = conn.cursor() + + # Lizenzdetails für Audit-Log abrufen + cur.execute(""" + SELECT l.license_key, c.name, l.license_type + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + license_info = cur.fetchone() + + cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) + + conn.commit() + + # Audit-Log + if license_info: + log_audit('DELETE', 'license', license_id, + old_values={ + 'license_key': license_info[0], + 'customer_name': license_info[1], + 'license_type': license_info[2] + }) + + cur.close() + conn.close() + + return redirect("/licenses") + +@app.route("/customers") +@login_required +def customers(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/customer/edit/", methods=["GET", "POST"]) +@login_required +def edit_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + old_customer = cur.fetchone() + + # Update customer + name = request.form["name"] + email = request.form["email"] + is_test = request.form.get("is_test") == "on" + + cur.execute(""" + UPDATE customers + SET name = %s, email = %s, is_test = %s + WHERE id = %s + """, (name, email, is_test, customer_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'customer', customer_id, + old_values={ + 'name': old_customer[0], + 'email': old_customer[1], + 'is_test': old_customer[2] + }, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei (immer der aktuelle Kunde) + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={customer_id}" + + return redirect(redirect_url) + + # Get customer data with licenses + cur.execute(""" + SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s + """, (customer_id,)) + + customer = cur.fetchone() + if not customer: + cur.close() + conn.close() + return "Kunde nicht gefunden", 404 + + + # Get customer's licenses + cur.execute(""" + SELECT id, license_key, license_type, valid_from, valid_until, is_active + FROM licenses + WHERE customer_id = %s + ORDER BY valid_until DESC + """, (customer_id,)) + + licenses = cur.fetchall() + + cur.close() + conn.close() + + if not customer: + return redirect("/customers-licenses") + + return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) + +@app.route("/customer/create", methods=["GET", "POST"]) +@login_required +def create_customer(): + """Erstellt einen neuen Kunden ohne Lizenz""" + if request.method == "POST": + name = request.form.get('name') + email = request.form.get('email') + is_test = request.form.get('is_test') == 'on' + + if not name or not email: + flash("Name und E-Mail sind Pflichtfelder!", "error") + return render_template("create_customer.html", username=session.get('username')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfen ob E-Mail bereits existiert + cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) + existing = cur.fetchone() + if existing: + flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") + return render_template("create_customer.html", username=session.get('username')) + + # Kunde erstellen + cur.execute(""" + INSERT INTO customers (name, email, created_at, is_test) + VALUES (%s, %s, %s, %s) RETURNING id + """, (name, email, datetime.now(), is_test)) + + customer_id = cur.fetchone()[0] + conn.commit() + + # Audit-Log + log_audit('CREATE', 'customer', customer_id, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") + return redirect(f"/customer/edit/{customer_id}") + + except Exception as e: + conn.rollback() + flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") + return render_template("create_customer.html", username=session.get('username')) + finally: + cur.close() + conn.close() + + # GET Request - Formular anzeigen + return render_template("create_customer.html", username=session.get('username')) + +@app.route("/customer/delete/", methods=["POST"]) +@login_required +def delete_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + # Prüfen ob Kunde Lizenzen hat + cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) + license_count = cur.fetchone()[0] + + if license_count > 0: + # Kunde hat Lizenzen - nicht löschen + cur.close() + conn.close() + return redirect("/customers") + + # Kundendetails für Audit-Log abrufen + cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) + customer_info = cur.fetchone() + + # Kunde löschen wenn keine Lizenzen vorhanden + cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) + + conn.commit() + + # Audit-Log + if customer_info: + log_audit('DELETE', 'customer', customer_id, + old_values={ + 'name': customer_info[0], + 'email': customer_info[1] + }) + + cur.close() + conn.close() + + return redirect("/customers") + +@app.route("/customers-licenses") +@login_required +def customers_licenses(): + """Kombinierte Ansicht für Kunden und deren Lizenzen""" + conn = get_connection() + cur = conn.cursor() + + # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + query = """ + SELECT + c.id, + c.name, + c.email, + c.created_at, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + """ + + if not show_test: + query += " WHERE c.is_test = FALSE" + + query += """ + GROUP BY c.id, c.name, c.email, c.created_at + ORDER BY c.name + """ + + cur.execute(query) + customers = cur.fetchall() + + # Hole ausgewählten Kunden nur wenn explizit in URL angegeben + selected_customer_id = request.args.get('customer_id', type=int) + licenses = [] + selected_customer = None + + if customers and selected_customer_id: + # Hole Daten des ausgewählten Kunden + for customer in customers: + if customer[0] == selected_customer_id: + selected_customer = customer + break + + # Hole Lizenzen des ausgewählten Kunden + if selected_customer: + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (selected_customer_id,)) + licenses = cur.fetchall() + + cur.close() + conn.close() + + return render_template("customers_licenses.html", + customers=customers, + selected_customer=selected_customer, + selected_customer_id=selected_customer_id, + licenses=licenses, + show_test=show_test) + +@app.route("/api/customer//licenses") +@login_required +def api_customer_licenses(customer_id): + """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Lizenzen des Kunden + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (customer_id,)) + + licenses = [] + for row in cur.fetchall(): + license_id = row[0] + + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for res_row in cur.fetchall(): + resource_info = { + 'id': res_row[0], + 'value': res_row[2], + 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' + } + + if res_row[1] == 'domain': + resources['domains'].append(resource_info) + elif res_row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif res_row[1] == 'phone': + resources['phones'].append(resource_info) + + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'license_type': row[2], + 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', + 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', + 'is_active': row[5], + 'status': row[6], + 'domain_count': row[7], # limit + 'ipv4_count': row[8], # limit + 'phone_count': row[9], # limit + 'device_limit': row[10], + 'active_devices': row[11], + 'actual_domain_count': row[12], # actual count + 'actual_ipv4_count': row[13], # actual count + 'actual_phone_count': row[14], # actual count + 'resources': resources + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'licenses': licenses, + 'count': len(licenses) + }) + +@app.route("/api/customer//quick-stats") +@login_required +def api_customer_quick_stats(customer_id): + """API-Endpoint für Schnellstatistiken eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Kundenstatistiken + cur.execute(""" + SELECT + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon + FROM licenses l + WHERE l.customer_id = %s + """, (customer_id,)) + + stats = cur.fetchone() + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'stats': { + 'total': stats[0], + 'active': stats[1], + 'expired': stats[2], + 'expiring_soon': stats[3] + } + }) + +@app.route("/api/license//quick-edit", methods=['POST']) +@login_required +def api_license_quick_edit(license_id): + """API-Endpoint für schnelle Lizenz-Bearbeitung""" + conn = get_connection() + cur = conn.cursor() + + try: + data = request.get_json() + + # Hole alte Werte für Audit-Log + cur.execute(""" + SELECT is_active, valid_until, license_type + FROM licenses WHERE id = %s + """, (license_id,)) + old_values = cur.fetchone() + + if not old_values: + return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 + + # Update-Felder vorbereiten + updates = [] + params = [] + new_values = {} + + if 'is_active' in data: + updates.append("is_active = %s") + params.append(data['is_active']) + new_values['is_active'] = data['is_active'] + + if 'valid_until' in data: + updates.append("valid_until = %s") + params.append(data['valid_until']) + new_values['valid_until'] = data['valid_until'] + + if 'license_type' in data: + updates.append("license_type = %s") + params.append(data['license_type']) + new_values['license_type'] = data['license_type'] + + if updates: + params.append(license_id) + cur.execute(f""" + UPDATE licenses + SET {', '.join(updates)} + WHERE id = %s + """, params) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'is_active': old_values[0], + 'valid_until': old_values[1].isoformat() if old_values[1] else None, + 'license_type': old_values[2] + }, + new_values=new_values) + + cur.close() + conn.close() + + return jsonify({'success': True}) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/api/license//resources") +@login_required +def api_license_resources(license_id): + """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" + conn = get_connection() + cur = conn.cursor() + + try: + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for row in cur.fetchall(): + resource_info = { + 'id': row[0], + 'value': row[2], + 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' + } + + if row[1] == 'domain': + resources['domains'].append(resource_info) + elif row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif row[1] == 'phone': + resources['phones'].append(resource_info) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'resources': resources + }) + + except Exception as e: + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/sessions") +@login_required +def sessions(): + conn = get_connection() + cur = conn.cursor() + + # Sortierparameter + active_sort = request.args.get('active_sort', 'last_heartbeat') + active_order = request.args.get('active_order', 'desc') + ended_sort = request.args.get('ended_sort', 'ended_at') + ended_order = request.args.get('ended_order', 'desc') + + # Whitelist für erlaubte Sortierfelder - Aktive Sessions + active_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'last_heartbeat': 's.last_heartbeat', + 'inactive': 'minutes_inactive' + } + + # Whitelist für erlaubte Sortierfelder - Beendete Sessions + ended_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'ended_at': 's.ended_at', + 'duration': 'duration_minutes' + } + + # Validierung + if active_sort not in active_sort_fields: + active_sort = 'last_heartbeat' + if ended_sort not in ended_sort_fields: + ended_sort = 'ended_at' + if active_order not in ['asc', 'desc']: + active_order = 'desc' + if ended_order not in ['asc', 'desc']: + ended_order = 'desc' + + # Aktive Sessions abrufen + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.user_agent, s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = TRUE + ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} + """) + active_sessions = cur.fetchall() + + # Inaktive Sessions der letzten 24 Stunden + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = FALSE + AND s.ended_at > NOW() - INTERVAL '24 hours' + ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} + LIMIT 50 + """) + recent_sessions = cur.fetchall() + + cur.close() + conn.close() + + return render_template("sessions.html", + active_sessions=active_sessions, + recent_sessions=recent_sessions, + active_sort=active_sort, + active_order=active_order, + ended_sort=ended_sort, + ended_order=ended_order, + username=session.get('username')) + +@app.route("/session/end/", methods=["POST"]) +@login_required +def end_session(session_id): + conn = get_connection() + cur = conn.cursor() + + # Session beenden + cur.execute(""" + UPDATE sessions + SET is_active = FALSE, ended_at = NOW() + WHERE id = %s AND is_active = TRUE + """, (session_id,)) + + conn.commit() + cur.close() + conn.close() + + return redirect("/sessions") + +@app.route("/export/licenses") +@login_required +def export_licenses(): + conn = get_connection() + cur = conn.cursor() + + # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) + include_test = request.args.get('include_test', 'false').lower() == 'true' + customer_id = request.args.get('customer_id', type=int) + + query = """ + SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, + CASE + WHEN l.is_active = FALSE THEN 'Deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' + ELSE 'Aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + """ + + # Build WHERE clause + where_conditions = [] + params = [] + + if not include_test: + where_conditions.append("l.is_test = FALSE") + + if customer_id: + where_conditions.append("l.customer_id = %s") + params.append(customer_id) + + if where_conditions: + query += " WHERE " + " AND ".join(where_conditions) + + query += " ORDER BY l.id" + + cur.execute(query, params) + + # Spaltennamen + columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', + 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') + df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') + + # Typ und Aktiv Status anpassen + df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) + df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'license', + additional_info=f"Export aller Lizenzen als {export_format.upper()}") + filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Lizenzen', index=False) + + # Formatierung + worksheet = writer.sheets['Lizenzen'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/audit") +@login_required +def export_audit(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_user = request.args.get('user', '') + filter_action = request.args.get('action', '') + filter_entity = request.args.get('entity', '') + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + params = [] + + if filter_user: + query += " AND username ILIKE %s" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + query += " ORDER BY timestamp DESC" + + cur.execute(query, params) + audit_logs = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for log in audit_logs: + action_text = { + 'CREATE': 'Erstellt', + 'UPDATE': 'Bearbeitet', + 'DELETE': 'Gelöscht', + 'LOGIN': 'Anmeldung', + 'LOGOUT': 'Abmeldung', + 'AUTO_LOGOUT': 'Auto-Logout', + 'EXPORT': 'Export', + 'GENERATE_KEY': 'Key generiert', + 'CREATE_BATCH': 'Batch erstellt', + 'BACKUP': 'Backup erstellt', + 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', + 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', + 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', + 'LOGIN_BLOCKED': 'Login-Blockiert', + 'RESTORE': 'Wiederhergestellt', + 'PASSWORD_CHANGE': 'Passwort geändert', + '2FA_ENABLED': '2FA aktiviert', + '2FA_DISABLED': '2FA deaktiviert' + }.get(log[3], log[3]) + + data.append({ + 'ID': log[0], + 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), + 'Benutzer': log[2], + 'Aktion': action_text, + 'Entität': log[4], + 'Entität-ID': log[5] or '', + 'IP-Adresse': log[8] or '', + 'Zusatzinfo': log[10] or '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'audit_log_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'audit_log', + additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Audit Log') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Audit Log'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/customers") +@login_required +def export_customers(): + conn = get_connection() + cur = conn.cursor() + + # Check if test data should be included + include_test = request.args.get('include_test', 'false').lower() == 'true' + + # Build query based on test data filter + if include_test: + # Include all customers + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + else: + # Exclude test customers and test licenses + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.is_test = FALSE + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + + cur.execute(query) + + # Spaltennamen + columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', + 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') + + # Testdaten formatting + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'customer', + additional_info=f"Export aller Kunden als {export_format.upper()}") + filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Kunden', index=False) + + # Formatierung + worksheet = writer.sheets['Kunden'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/sessions") +@login_required +def export_sessions(): + conn = get_connection() + cur = conn.cursor() + + # Holen des Session-Typs (active oder ended) + session_type = request.args.get('type', 'active') + export_format = request.args.get('format', 'excel') + + # Daten je nach Typ abrufen + if session_type == 'active': + # Aktive Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = true + ORDER BY s.last_heartbeat DESC + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Aktive Sessions' + filename_prefix = 'aktive_sessions' + else: + # Beendete Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = false AND s.ended_at IS NOT NULL + ORDER BY s.ended_at DESC + LIMIT 1000 + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] if sess[6] else 0 + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Beendete Sessions' + filename_prefix = 'beendete_sessions' + + cur.close() + conn.close() + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'{filename_prefix}_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'sessions', + additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name=sheet_name) + + # Spaltenbreiten anpassen + worksheet = writer.sheets[sheet_name] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/resources") +@login_required +def export_resources(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_type = request.args.get('type', '') + filter_status = request.args.get('status', '') + search_query = request.args.get('search', '') + show_test = request.args.get('show_test', 'false').lower() == 'true' + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, + r.created_at, r.status_changed_at, + l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type + FROM resource_pools r + LEFT JOIN licenses l ON r.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE 1=1 + """ + params = [] + + # Filter für Testdaten + if not show_test: + query += " AND (r.is_test = false OR r.is_test IS NULL)" + + # Filter für Ressourcentyp + if filter_type: + query += " AND r.resource_type = %s" + params.append(filter_type) + + # Filter für Status + if filter_status: + query += " AND r.status = %s" + params.append(filter_status) + + # Suchfilter + if search_query: + query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" + params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) + + query += " ORDER BY r.id DESC" + + cur.execute(query, params) + resources = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for res in resources: + status_text = { + 'available': 'Verfügbar', + 'allocated': 'Zugewiesen', + 'quarantine': 'Quarantäne' + }.get(res[3], res[3]) + + type_text = { + 'domain': 'Domain', + 'ipv4': 'IPv4', + 'phone': 'Telefon' + }.get(res[1], res[1]) + + data.append({ + 'ID': res[0], + 'Typ': type_text, + 'Ressource': res[2], + 'Status': status_text, + 'Lizenzschlüssel': res[7] or '', + 'Kunde': res[8] or '', + 'Kunden-Email': res[9] or '', + 'Lizenztyp': res[10] or '', + 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', + 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'resources_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'resources', + additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Resources') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Resources'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/audit") +@login_required +def audit_log(): + conn = get_connection() + cur = conn.cursor() + + # Parameter + filter_user = request.args.get('user', '').strip() + filter_action = request.args.get('action', '').strip() + filter_entity = request.args.get('entity', '').strip() + page = request.args.get('page', 1, type=int) + sort = request.args.get('sort', 'timestamp') + order = request.args.get('order', 'desc') + per_page = 50 + + # Whitelist für erlaubte Sortierfelder + allowed_sort_fields = { + 'timestamp': 'timestamp', + 'username': 'username', + 'action': 'action', + 'entity': 'entity_type', + 'ip': 'ip_address' + } + + # Validierung + if sort not in allowed_sort_fields: + sort = 'timestamp' + if order not in ['asc', 'desc']: + order = 'desc' + + sort_field = allowed_sort_fields[sort] + + # SQL Query mit optionalen Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + + params = [] + + # Filter + if filter_user: + query += " AND LOWER(username) LIKE LOWER(%s)" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + # Gesamtanzahl für Pagination + count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" + cur.execute(count_query, params) + total = cur.fetchone()[0] + + # Pagination + offset = (page - 1) * per_page + query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + logs = cur.fetchall() + + # JSON-Werte parsen + parsed_logs = [] + for log in logs: + parsed_log = list(log) + # old_values und new_values sind bereits Dictionaries (JSONB) + # Keine Konvertierung nötig + parsed_logs.append(parsed_log) + + # Pagination Info + total_pages = (total + per_page - 1) // per_page + + cur.close() + conn.close() + + return render_template("audit_log.html", + logs=parsed_logs, + filter_user=filter_user, + filter_action=filter_action, + filter_entity=filter_entity, + page=page, + total_pages=total_pages, + total=total, + sort=sort, + order=order, + 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) + +@app.route("/backup/delete/", methods=["DELETE"]) +@login_required +def delete_backup(backup_id): + """Löscht ein Backup""" + conn = get_connection() + cur = conn.cursor() + + try: + # Backup-Informationen abrufen + cur.execute(""" + SELECT filename, filepath + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + if not backup_info: + return jsonify({ + 'success': False, + 'message': 'Backup nicht gefunden' + }), 404 + + filename, filepath = backup_info + filepath = Path(filepath) + + # Datei löschen, wenn sie existiert + if filepath.exists(): + filepath.unlink() + + # Aus Datenbank löschen + cur.execute(""" + DELETE FROM backup_history + WHERE id = %s + """, (backup_id,)) + + conn.commit() + + # Audit-Log + log_audit('DELETE', 'backup', backup_id, + additional_info=f"Backup gelöscht: {filename}") + + return jsonify({ + 'success': True, + 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' + }) + + except Exception as e: + conn.rollback() + return jsonify({ + 'success': False, + 'message': f'Fehler beim Löschen des Backups: {str(e)}' + }), 500 + finally: + cur.close() + conn.close() + +@app.route("/security/blocked-ips") +@login_required +def blocked_ips(): + """Zeigt alle gesperrten IPs an""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT + ip_address, + attempt_count, + first_attempt, + last_attempt, + blocked_until, + last_username_tried, + last_error_message + FROM login_attempts + WHERE blocked_until IS NOT NULL + ORDER BY blocked_until DESC + """) + + blocked_ips_list = [] + for ip in cur.fetchall(): + blocked_ips_list.append({ + 'ip_address': ip[0], + 'attempt_count': ip[1], + 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), + 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), + 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), + 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), + 'last_username': ip[5], + 'last_error': ip[6] + }) + + cur.close() + conn.close() + + return render_template("blocked_ips.html", + blocked_ips=blocked_ips_list, + username=session.get('username')) + +@app.route("/security/unblock-ip", methods=["POST"]) +@login_required +def unblock_ip(): + """Entsperrt eine IP-Adresse""" + ip_address = request.form.get('ip_address') + + if ip_address: + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + UPDATE login_attempts + SET blocked_until = NULL + WHERE ip_address = %s + """, (ip_address,)) + + conn.commit() + cur.close() + conn.close() + + # Audit-Log + log_audit('UNBLOCK_IP', 'security', + additional_info=f"IP {ip_address} manuell entsperrt") + + return redirect(url_for('blocked_ips')) + +@app.route("/security/clear-attempts", methods=["POST"]) +@login_required +def clear_attempts(): + """Löscht alle Login-Versuche für eine IP""" + ip_address = request.form.get('ip_address') + + if ip_address: + reset_login_attempts(ip_address) + + # Audit-Log + log_audit('CLEAR_ATTEMPTS', 'security', + additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") + + return redirect(url_for('blocked_ips')) + +# API Endpoints for License Management +@app.route("/api/license//toggle", methods=["POST"]) +@login_required +def toggle_license_api(license_id): + """Toggle license active status via API""" + try: + data = request.get_json() + is_active = data.get('is_active', False) + + conn = get_connection() + cur = conn.cursor() + + # Update license status + cur.execute(""" + UPDATE licenses + SET is_active = %s + WHERE id = %s + """, (is_active, license_id)) + + conn.commit() + + # Log the action + log_audit('UPDATE', 'license', license_id, + new_values={'is_active': is_active}, + additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-activate", methods=["POST"]) +@login_required +def bulk_activate_licenses(): + """Activate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = TRUE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': True, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen aktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) +@login_required +def bulk_deactivate_licenses(): + """Deactivate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = FALSE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': False, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen deaktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/license//devices") +@login_required +def get_license_devices(license_id): + """Hole alle registrierten Geräte einer Lizenz""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und hole device_limit + cur.execute(""" + SELECT device_limit FROM licenses WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit = license_data[0] + + # Hole alle Geräte für diese Lizenz + cur.execute(""" + SELECT id, hardware_id, device_name, operating_system, + first_seen, last_seen, is_active, ip_address + FROM device_registrations + WHERE license_id = %s + ORDER BY is_active DESC, last_seen DESC + """, (license_id,)) + + devices = [] + for row in cur.fetchall(): + devices.append({ + 'id': row[0], + 'hardware_id': row[1], + 'device_name': row[2] or 'Unbekanntes Gerät', + 'operating_system': row[3] or 'Unbekannt', + 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', + 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', + 'is_active': row[6], + 'ip_address': row[7] or '-' + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'devices': devices, + 'device_limit': device_limit, + 'active_count': sum(1 for d in devices if d['is_active']) + }) + + except Exception as e: + logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 + +@app.route("/api/license//register-device", methods=["POST"]) +def register_device(license_id): + """Registriere ein neues Gerät für eine Lizenz""" + try: + data = request.get_json() + hardware_id = data.get('hardware_id') + device_name = data.get('device_name', '') + operating_system = data.get('operating_system', '') + + if not hardware_id: + return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und aktiv ist + cur.execute(""" + SELECT device_limit, is_active, valid_until + FROM licenses + WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit, is_active, valid_until = license_data + + # Prüfe ob Lizenz aktiv und gültig ist + if not is_active: + return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 + + if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): + return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 + + # Prüfe ob Gerät bereits registriert ist + cur.execute(""" + SELECT id, is_active FROM device_registrations + WHERE license_id = %s AND hardware_id = %s + """, (license_id, hardware_id)) + existing_device = cur.fetchone() + + if existing_device: + device_id, is_device_active = existing_device + if is_device_active: + # Gerät ist bereits aktiv, update last_seen + cur.execute(""" + UPDATE device_registrations + SET last_seen = CURRENT_TIMESTAMP, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) + else: + # Gerät war deaktiviert, prüfe ob wir es reaktivieren können + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Reaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = TRUE, + last_seen = CURRENT_TIMESTAMP, + deactivated_at = NULL, + deactivated_by = NULL, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) + + # Neues Gerät - prüfe Gerätelimit + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Registriere neues Gerät + cur.execute(""" + INSERT INTO device_registrations + (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, (license_id, hardware_id, device_name, operating_system, + get_client_ip(), request.headers.get('User-Agent', ''))) + device_id = cur.fetchone()[0] + + conn.commit() + + # Audit Log + log_audit('DEVICE_REGISTER', 'device', device_id, + new_values={'license_id': license_id, 'hardware_id': hardware_id}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) + + except Exception as e: + logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 + +@app.route("/api/license//deactivate-device/", methods=["POST"]) +@login_required +def deactivate_device(license_id, device_id): + """Deaktiviere ein registriertes Gerät""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob das Gerät zu dieser Lizenz gehört + cur.execute(""" + SELECT id FROM device_registrations + WHERE id = %s AND license_id = %s AND is_active = TRUE + """, (device_id, license_id)) + + if not cur.fetchone(): + return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 + + # Deaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = FALSE, + deactivated_at = CURRENT_TIMESTAMP, + deactivated_by = %s + WHERE id = %s + """, (session['username'], device_id)) + + conn.commit() + + # Audit Log + log_audit('DEVICE_DEACTIVATE', 'device', device_id, + old_values={'is_active': True}, + new_values={'is_active': False}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) + + except Exception as e: + logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 + +@app.route("/api/licenses/bulk-delete", methods=["POST"]) +@login_required +def bulk_delete_licenses(): + """Delete multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Get license info for audit log (nur Live-Daten) + cur.execute(""" + SELECT license_key + FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + license_keys = [row[0] for row in cur.fetchall()] + + # Delete all selected licenses (nur Live-Daten) + cur.execute(""" + DELETE FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_DELETE', 'licenses', None, + old_values={'license_keys': license_keys, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen gelöscht") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +# ===================== RESOURCE POOL MANAGEMENT ===================== + +@app.route('/resources') +@login_required +def resources(): + """Resource Pool Hauptübersicht""" + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + # Statistiken abrufen + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = %s + GROUP BY resource_type + """, (show_test,)) + + stats = {} + for row in cur.fetchall(): + stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + } + + # Letzte Aktivitäten (gefiltert nach Test/Live) + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rp.resource_type, + rp.resource_value, + rh.details + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + WHERE rp.is_test = %s + ORDER BY rh.action_at DESC + LIMIT 10 + """, (show_test,)) + recent_activities = cur.fetchall() + + # Ressourcen-Liste mit Pagination + page = request.args.get('page', 1, type=int) + per_page = 50 + offset = (page - 1) * per_page + + resource_type = request.args.get('type', '') + status_filter = request.args.get('status', '') + search = request.args.get('search', '') + + # Sortierung + sort_by = request.args.get('sort', 'id') + sort_order = request.args.get('order', 'desc') + + # Base Query + query = """ + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + rp.allocated_to_license, + l.license_key, + c.name as customer_name, + rp.status_changed_at, + rp.quarantine_reason, + rp.quarantine_until, + c.id as customer_id + FROM resource_pools rp + LEFT JOIN licenses l ON rp.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rp.is_test = %s + """ + params = [show_test] + + if resource_type: + query += " AND rp.resource_type = %s" + params.append(resource_type) + + if status_filter: + query += " AND rp.status = %s" + params.append(status_filter) + + if search: + query += " AND rp.resource_value ILIKE %s" + params.append(f'%{search}%') + + # Count total + count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" + cur.execute(count_query, params) + total = cur.fetchone()[0] + total_pages = (total + per_page - 1) // per_page + + # Get paginated results with dynamic sorting + sort_column_map = { + 'id': 'rp.id', + 'type': 'rp.resource_type', + 'resource': 'rp.resource_value', + 'status': 'rp.status', + 'assigned': 'c.name', + 'changed': 'rp.status_changed_at' + } + + sort_column = sort_column_map.get(sort_by, 'rp.id') + sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' + + query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + resources = cur.fetchall() + + cur.close() + conn.close() + + return render_template('resources.html', + stats=stats, + resources=resources, + recent_activities=recent_activities, + page=page, + total_pages=total_pages, + total=total, + resource_type=resource_type, + status_filter=status_filter, + search=search, + show_test=show_test, + sort_by=sort_by, + sort_order=sort_order, + datetime=datetime, + timedelta=timedelta) + +@app.route('/resources/add', methods=['GET', 'POST']) +@login_required +def add_resources(): + """Ressourcen zum Pool hinzufügen""" + # Hole show_test Parameter für die Anzeige + show_test = request.args.get('show_test', 'false').lower() == 'true' + + if request.method == 'POST': + resource_type = request.form.get('resource_type') + resources_text = request.form.get('resources_text', '') + is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten + + # Parse resources (one per line) + resources = [r.strip() for r in resources_text.split('\n') if r.strip()] + + if not resources: + flash('Keine Ressourcen angegeben', 'error') + return redirect(url_for('add_resources', show_test=show_test)) + + conn = get_connection() + cur = conn.cursor() + + added = 0 + duplicates = 0 + + for resource_value in resources: + try: + cur.execute(""" + INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) + VALUES (%s, %s, %s, %s) + ON CONFLICT (resource_type, resource_value) DO NOTHING + """, (resource_type, resource_value, session['username'], is_test)) + + if cur.rowcount > 0: + added += 1 + # Get the inserted ID + cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", + (resource_type, resource_value)) + resource_id = cur.fetchone()[0] + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'created', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + else: + duplicates += 1 + + except Exception as e: + app.logger.error(f"Error adding resource {resource_value}: {e}") + + conn.commit() + cur.close() + conn.close() + + log_audit('CREATE', 'resource_pool', None, + new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, + additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") + + flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') + return redirect(url_for('resources', show_test=show_test)) + + return render_template('add_resources.html', show_test=show_test) + +@app.route('/resources/quarantine/', methods=['POST']) +@login_required +def quarantine_resource(resource_id): + """Ressource in Quarantäne setzen""" + reason = request.form.get('reason', 'review') + until_date = request.form.get('until_date') + notes = request.form.get('notes', '') + + conn = get_connection() + cur = conn.cursor() + + # Get current resource info + cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) + resource = cur.fetchone() + + if not resource: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + old_status = resource[2] + + # Update resource + cur.execute(""" + UPDATE resource_pools + SET status = 'quarantine', + quarantine_reason = %s, + quarantine_until = %s, + notes = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) + VALUES (%s, 'quarantined', %s, %s, %s) + """, (resource_id, session['username'], get_client_ip(), + Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource', resource_id, + old_values={'status': old_status}, + new_values={'status': 'quarantine', 'reason': reason}, + additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") + + flash('Ressource in Quarantäne gesetzt', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/resources/release', methods=['POST']) +@login_required +def release_resources(): + """Ressourcen aus Quarantäne freigeben""" + resource_ids = request.form.getlist('resource_ids') + + if not resource_ids: + flash('Keine Ressourcen ausgewählt', 'error') + return redirect(url_for('resources')) + + conn = get_connection() + cur = conn.cursor() + + released = 0 + for resource_id in resource_ids: + cur.execute(""" + UPDATE resource_pools + SET status = 'available', + quarantine_reason = NULL, + quarantine_until = NULL, + allocated_to_license = NULL, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s AND status = 'quarantine' + """, (session['username'], resource_id)) + + if cur.rowcount > 0: + released += 1 + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'released', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource_pool', None, + new_values={'released': released}, + additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") + + flash(f'{released} Ressourcen freigegeben', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/api/resources/allocate', methods=['POST']) +@login_required +def allocate_resources_api(): + """API für Ressourcen-Zuweisung bei Lizenzerstellung""" + data = request.json + license_id = data.get('license_id') + domain_count = data.get('domain_count', 1) + ipv4_count = data.get('ipv4_count', 1) + phone_count = data.get('phone_count', 1) + + conn = get_connection() + cur = conn.cursor() + + try: + allocated = {'domains': [], 'ipv4s': [], 'phones': []} + + # Allocate domains + if domain_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' + LIMIT %s FOR UPDATE + """, (domain_count,)) + domains = cur.fetchall() + + if len(domains) < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") + + for domain_id, domain_value in domains: + # Update resource status + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], domain_id)) + + # Create assignment + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, domain_id, session['username'])) + + # Log history + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (domain_id, license_id, session['username'], get_client_ip())) + + allocated['domains'].append(domain_value) + + # Allocate IPv4s (similar logic) + if ipv4_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' + LIMIT %s FOR UPDATE + """, (ipv4_count,)) + ipv4s = cur.fetchall() + + if len(ipv4s) < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") + + for ipv4_id, ipv4_value in ipv4s: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], ipv4_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, ipv4_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (ipv4_id, license_id, session['username'], get_client_ip())) + + allocated['ipv4s'].append(ipv4_value) + + # Allocate phones (similar logic) + if phone_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' + LIMIT %s FOR UPDATE + """, (phone_count,)) + phones = cur.fetchall() + + if len(phones) < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar") + + for phone_id, phone_value in phones: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], phone_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, phone_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (phone_id, license_id, session['username'], get_client_ip())) + + allocated['phones'].append(phone_value) + + # Update license resource counts + cur.execute(""" + UPDATE licenses + SET domain_count = %s, + ipv4_count = %s, + phone_count = %s + WHERE id = %s + """, (domain_count, ipv4_count, phone_count, license_id)) + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'allocated': allocated + }) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({ + 'success': False, + 'error': str(e) + }), 400 + +@app.route('/api/resources/check-availability', methods=['GET']) +@login_required +def check_resource_availability(): + """Prüft verfügbare Ressourcen""" + resource_type = request.args.get('type', '') + count = request.args.get('count', 10, type=int) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + conn = get_connection() + cur = conn.cursor() + + if resource_type: + # Spezifische Ressourcen für einen Typ + cur.execute(""" + SELECT id, resource_value + FROM resource_pools + WHERE status = 'available' + AND resource_type = %s + AND is_test = %s + ORDER BY resource_value + LIMIT %s + """, (resource_type, show_test, count)) + + resources = [] + for row in cur.fetchall(): + resources.append({ + 'id': row[0], + 'value': row[1] + }) + + cur.close() + conn.close() + + return jsonify({ + 'available': resources, + 'type': resource_type, + 'count': len(resources) + }) + else: + # Zusammenfassung aller Typen + cur.execute(""" + SELECT + resource_type, + COUNT(*) as available + FROM resource_pools + WHERE status = 'available' + AND is_test = %s + GROUP BY resource_type + """, (show_test,)) + + availability = {} + for row in cur.fetchall(): + availability[row[0]] = row[1] + + cur.close() + conn.close() + + return jsonify(availability) + +@app.route('/api/global-search', methods=['GET']) +@login_required +def global_search(): + """Global search API endpoint for searching customers and licenses""" + query = request.args.get('q', '').strip() + + if not query or len(query) < 2: + return jsonify({'customers': [], 'licenses': []}) + + conn = get_connection() + cur = conn.cursor() + + # Search pattern with wildcards + search_pattern = f'%{query}%' + + # Search customers + cur.execute(""" + SELECT id, name, email, company_name + FROM customers + WHERE (LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + OR LOWER(company_name) LIKE LOWER(%s)) + AND is_test = FALSE + ORDER BY name + LIMIT 5 + """, (search_pattern, search_pattern, search_pattern)) + + customers = [] + for row in cur.fetchall(): + customers.append({ + 'id': row[0], + 'name': row[1], + 'email': row[2], + 'company_name': row[3] + }) + + # Search licenses + cur.execute(""" + SELECT l.id, l.license_key, c.name as customer_name + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE LOWER(l.license_key) LIKE LOWER(%s) + AND l.is_test = FALSE + ORDER BY l.created_at DESC + LIMIT 5 + """, (search_pattern,)) + + licenses = [] + for row in cur.fetchall(): + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'customer_name': row[2] + }) + + cur.close() + conn.close() + + return jsonify({ + 'customers': customers, + 'licenses': licenses + }) + +@app.route('/resources/history/') +@login_required +def resource_history(resource_id): + """Zeigt die komplette Historie einer Ressource""" + conn = get_connection() + cur = conn.cursor() + + # Get complete resource info using named columns + cur.execute(""" + SELECT id, resource_type, resource_value, status, allocated_to_license, + status_changed_at, status_changed_by, quarantine_reason, + quarantine_until, created_at, notes + FROM resource_pools + WHERE id = %s + """, (resource_id,)) + row = cur.fetchone() + + if not row: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + # Create resource object with named attributes + resource = { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'allocated_to_license': row[4], + 'status_changed_at': row[5], + 'status_changed_by': row[6], + 'quarantine_reason': row[7], + 'quarantine_until': row[8], + 'created_at': row[9], + 'notes': row[10] + } + + # Get license info if allocated + license_info = None + if resource['allocated_to_license']: + cur.execute("SELECT license_key FROM licenses WHERE id = %s", + (resource['allocated_to_license'],)) + lic = cur.fetchone() + if lic: + license_info = {'license_key': lic[0]} + + # Get history with named columns + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rh.details, + rh.license_id, + rh.ip_address + FROM resource_history rh + WHERE rh.resource_id = %s + ORDER BY rh.action_at DESC + """, (resource_id,)) + + history = [] + for row in cur.fetchall(): + history.append({ + 'action': row[0], + 'action_by': row[1], + 'action_at': row[2], + 'details': row[3], + 'license_id': row[4], + 'ip_address': row[5] + }) + + cur.close() + conn.close() + + # Convert to object-like for template + class ResourceObj: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + + resource_obj = ResourceObj(resource) + history_objs = [ResourceObj(h) for h in history] + + return render_template('resource_history.html', + resource=resource_obj, + license_info=license_info, + history=history_objs) + +@app.route('/resources/metrics') +@login_required +def resources_metrics(): + """Dashboard für Resource Metrics und Reports""" + conn = get_connection() + cur = conn.cursor() + + # Overall stats with fallback values + cur.execute(""" + SELECT + COUNT(DISTINCT resource_id) as total_resources, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(cost), 0) as total_cost, + COALESCE(SUM(revenue), 0) as total_revenue, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + """) + row = cur.fetchone() + + # Calculate ROI + roi = 0 + if row[2] > 0: # if total_cost > 0 + roi = row[3] / row[2] # revenue / cost + + stats = { + 'total_resources': row[0] or 0, + 'avg_performance': row[1] or 0, + 'total_cost': row[2] or 0, + 'total_revenue': row[3] or 0, + 'total_issues': row[4] or 0, + 'roi': roi + } + + # Performance by type + cur.execute(""" + SELECT + rp.resource_type, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COUNT(DISTINCT rp.id) as resource_count + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY rp.resource_type + ORDER BY rp.resource_type + """) + performance_by_type = cur.fetchall() + + # Utilization data + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) as total, + ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent + FROM resource_pools + GROUP BY resource_type + """) + utilization_rows = cur.fetchall() + utilization_data = [ + { + 'type': row[0].upper(), + 'allocated': row[1], + 'total': row[2], + 'allocated_percent': row[3] + } + for row in utilization_rows + ] + + # Top performing resources + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COALESCE(SUM(rm.revenue), 0) as total_revenue, + COALESCE(SUM(rm.cost), 1) as total_cost, + CASE + WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 + ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) + END as roi + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rp.status != 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value + HAVING AVG(rm.performance_score) IS NOT NULL + ORDER BY avg_score DESC + LIMIT 10 + """) + top_rows = cur.fetchall() + top_performers = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'avg_score': row[3], + 'roi': row[6] + } + for row in top_rows + ] + + # Resources with issues + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + COALESCE(SUM(rm.issues_count), 0) as total_issues + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rm.issues_count > 0 OR rp.status = 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + HAVING SUM(rm.issues_count) > 0 + ORDER BY total_issues DESC + LIMIT 10 + """) + problem_rows = cur.fetchall() + problem_resources = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'total_issues': row[4] + } + for row in problem_rows + ] + + # Daily metrics for trend chart (last 30 days) + cur.execute(""" + SELECT + metric_date, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY metric_date + ORDER BY metric_date + """) + daily_rows = cur.fetchall() + daily_metrics = [ + { + 'date': row[0].strftime('%d.%m'), + 'performance': float(row[1]), + 'issues': int(row[2]) + } + for row in daily_rows + ] + + cur.close() + conn.close() + + return render_template('resource_metrics.html', + stats=stats, + performance_by_type=performance_by_type, + utilization_data=utilization_data, + top_performers=top_performers, + problem_resources=problem_resources, + daily_metrics=daily_metrics) + +@app.route('/resources/report', methods=['GET']) +@login_required +def resources_report(): + """Generiert Ressourcen-Reports oder zeigt Report-Formular""" + # Prüfe ob Download angefordert wurde + if request.args.get('download') == 'true': + report_type = request.args.get('type', 'usage') + format_type = request.args.get('format', 'excel') + date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) + date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) + + conn = get_connection() + cur = conn.cursor() + + if report_type == 'usage': + # Auslastungsreport + query = """ + SELECT + rp.resource_type, + rp.resource_value, + rp.status, + COUNT(DISTINCT rh.license_id) as unique_licenses, + COUNT(rh.id) as total_allocations, + MIN(rh.action_at) as first_used, + MAX(rh.action_at) as last_used + FROM resource_pools rp + LEFT JOIN resource_history rh ON rp.id = rh.resource_id + AND rh.action = 'allocated' + AND rh.action_at BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + ORDER BY rp.resource_type, total_allocations DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] + + elif report_type == 'performance': + # Performance-Report + query = """ + SELECT + rp.resource_type, + rp.resource_value, + AVG(rm.performance_score) as avg_performance, + SUM(rm.usage_count) as total_usage, + SUM(rm.revenue) as total_revenue, + SUM(rm.cost) as total_cost, + SUM(rm.revenue - rm.cost) as profit, + SUM(rm.issues_count) as total_issues + FROM resource_pools rp + JOIN resource_metrics rm ON rp.id = rm.resource_id + WHERE rm.metric_date BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value + ORDER BY profit DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] + + elif report_type == 'compliance': + # Compliance-Report + query = """ + SELECT + rh.action_at, + rh.action, + rh.action_by, + rp.resource_type, + rp.resource_value, + l.license_key, + c.name as customer_name, + rh.ip_address + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + LEFT JOIN licenses l ON rh.license_id = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rh.action_at BETWEEN %s AND %s + ORDER BY rh.action_at DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] + + else: # inventory report + # Inventar-Report + query = """ + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + GROUP BY resource_type + ORDER BY resource_type + """ + cur.execute(query) + columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] + + # Convert to DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + cur.close() + conn.close() + + # Generate file + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f"resource_report_{report_type}_{timestamp}" + + if format_type == 'excel': + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Report', index=False) + + # Auto-adjust columns width + worksheet = writer.sheets['Report'] + for column in worksheet.columns: + max_length = 0 + column = [cell for cell in column] + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = (max_length + 2) + worksheet.column_dimensions[column[0].column_letter].width = adjusted_width + + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx') + + else: # CSV + output = io.StringIO() + df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv') + + # Wenn kein Download, zeige Report-Formular + return render_template('resource_report.html', + datetime=datetime, + timedelta=timedelta, + username=session.get('username')) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/v2_adminpanel/app.py.old b/v2_adminpanel/app.py.old index 3849500..34c3f29 100644 --- a/v2_adminpanel/app.py.old +++ b/v2_adminpanel/app.py.old @@ -1,5021 +1,5021 @@ -import os -import time -import json -import logging -import requests -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - get_client_ip, check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - - -# Login decorator -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if 'logged_in' not in session: - return redirect(url_for('login')) - - # Prüfe ob Session abgelaufen ist - if 'last_activity' in session: - last_activity = datetime.fromisoformat(session['last_activity']) - time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity - - # Debug-Logging - app.logger.info(f"Session check for {session.get('username', 'unknown')}: " - f"Last activity: {last_activity}, " - f"Time since: {time_since_activity.total_seconds()} seconds") - - if time_since_activity > timedelta(minutes=5): - # Session abgelaufen - Logout - username = session.get('username', 'unbekannt') - app.logger.info(f"Session timeout for user {username} - auto logout") - # Audit-Log für automatischen Logout (vor session.clear()!) - try: - log_audit('AUTO_LOGOUT', 'session', additional_info={'reason': 'Session timeout (5 minutes)', 'username': username}) - except: - pass - session.clear() - flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning') - return redirect(url_for('login')) - - # Aktivität NICHT automatisch aktualisieren - # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) - return f(*args, **kwargs) - return decorated_function - -# DB-Verbindung mit UTF-8 Encoding -def get_connection(): - conn = psycopg2.connect( - host=os.getenv("POSTGRES_HOST", "postgres"), - port=os.getenv("POSTGRES_PORT", "5432"), - dbname=os.getenv("POSTGRES_DB"), - user=os.getenv("POSTGRES_USER"), - password=os.getenv("POSTGRES_PASSWORD"), - options='-c client_encoding=UTF8' - ) - conn.set_client_encoding('UTF8') - return conn - -# User Authentication Helper Functions -def hash_password(password): - """Hash a password using bcrypt""" - return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - -def verify_password(password, hashed): - """Verify a password against its hash""" - return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) - -def get_user_by_username(username): - """Get user from database by username""" - conn = get_connection() - cur = conn.cursor() - try: - cur.execute(""" - SELECT id, username, password_hash, email, totp_secret, totp_enabled, - backup_codes, last_password_change, failed_2fa_attempts - FROM users WHERE username = %s - """, (username,)) - user = cur.fetchone() - if user: - return { - 'id': user[0], - 'username': user[1], - 'password_hash': user[2], - 'email': user[3], - 'totp_secret': user[4], - 'totp_enabled': user[5], - 'backup_codes': user[6], - 'last_password_change': user[7], - 'failed_2fa_attempts': user[8] - } - return None - finally: - cur.close() - conn.close() - -def generate_totp_secret(): - """Generate a new TOTP secret""" - return pyotp.random_base32() - -def generate_qr_code(username, totp_secret): - """Generate QR code for TOTP setup""" - totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( - name=username, - issuer_name='V2 Admin Panel' - ) - - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(totp_uri) - qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - buf = BytesIO() - img.save(buf, format='PNG') - buf.seek(0) - - return base64.b64encode(buf.getvalue()).decode() - -def verify_totp(totp_secret, token): - """Verify a TOTP token""" - totp = pyotp.TOTP(totp_secret) - return totp.verify(token, valid_window=1) - -def generate_backup_codes(count=8): - """Generate backup codes for 2FA recovery""" - codes = [] - for _ in range(count): - code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) - codes.append(code) - return codes - -def hash_backup_code(code): - """Hash a backup code for storage""" - return hashlib.sha256(code.encode()).hexdigest() - -def verify_backup_code(code, hashed_codes): - """Verify a backup code against stored hashes""" - code_hash = hashlib.sha256(code.encode()).hexdigest() - return code_hash in hashed_codes - -# Audit-Log-Funktion -def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): - """Protokolliert Änderungen im Audit-Log""" - conn = get_connection() - cur = conn.cursor() - - try: - username = session.get('username', 'system') - ip_address = get_client_ip() if request else None - user_agent = request.headers.get('User-Agent') if request else None - - # Debug logging - app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") - - # Konvertiere Dictionaries zu JSONB - old_json = Json(old_values) if old_values else None - new_json = Json(new_values) if new_values else None - - cur.execute(""" - INSERT INTO audit_log - (username, action, entity_type, entity_id, old_values, new_values, - ip_address, user_agent, additional_info) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - """, (username, action, entity_type, entity_id, old_json, new_json, - ip_address, user_agent, additional_info)) - - conn.commit() - except Exception as e: - print(f"Audit log error: {e}") - conn.rollback() - finally: - 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(ZoneInfo("Europe/Berlin")).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 -) - -# Rate-Limiting Funktionen -def get_client_ip(): - """Ermittelt die echte IP-Adresse des Clients""" - # Debug logging - app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") - - # Try X-Real-IP first (set by nginx) - if request.headers.get('X-Real-IP'): - return request.headers.get('X-Real-IP') - # Then X-Forwarded-For - elif request.headers.get('X-Forwarded-For'): - # X-Forwarded-For can contain multiple IPs, take the first one - return request.headers.get('X-Forwarded-For').split(',')[0].strip() - # Fallback to remote_addr - else: - return request.remote_addr - -def check_ip_blocked(ip_address): - """Prüft ob eine IP-Adresse gesperrt ist""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT blocked_until FROM login_attempts - WHERE ip_address = %s AND blocked_until IS NOT NULL - """, (ip_address,)) - - result = cur.fetchone() - cur.close() - conn.close() - - if result and result[0]: - if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None): - return True, result[0] - return False, None - -def record_failed_attempt(ip_address, username): - """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" - conn = get_connection() - cur = conn.cursor() - - # Random Fehlermeldung - error_message = random.choice(FAIL_MESSAGES) - - try: - # Prüfen ob IP bereits existiert - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - - if result: - # Update bestehenden Eintrag - new_count = result[0] + 1 - blocked_until = None - - if new_count >= MAX_LOGIN_ATTEMPTS: - blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS) - # E-Mail-Benachrichtigung (wenn aktiviert) - if os.getenv("EMAIL_ENABLED", "false").lower() == "true": - send_security_alert_email(ip_address, username, new_count) - - cur.execute(""" - UPDATE login_attempts - SET attempt_count = %s, - last_attempt = CURRENT_TIMESTAMP, - blocked_until = %s, - last_username_tried = %s, - last_error_message = %s - WHERE ip_address = %s - """, (new_count, blocked_until, username, error_message, ip_address)) - else: - # Neuen Eintrag erstellen - cur.execute(""" - INSERT INTO login_attempts - (ip_address, attempt_count, last_username_tried, last_error_message) - VALUES (%s, 1, %s, %s) - """, (ip_address, username, error_message)) - - conn.commit() - - # Audit-Log - log_audit('LOGIN_FAILED', 'user', - additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}") - - except Exception as e: - print(f"Rate limiting error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - - return error_message - -def reset_login_attempts(ip_address): - """Setzt die Login-Versuche für eine IP zurück""" - conn = get_connection() - cur = conn.cursor() - - try: - cur.execute(""" - DELETE FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - conn.commit() - except Exception as e: - print(f"Reset attempts error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - -def get_login_attempts(ip_address): - """Gibt die Anzahl der Login-Versuche für eine IP zurück""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - cur.close() - conn.close() - - return result[0] if result else 0 - -def send_security_alert_email(ip_address, username, attempt_count): - """Sendet eine Sicherheitswarnung per E-Mail""" - subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche" - body = f""" - WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt! - - IP-Adresse: {ip_address} - Versuchter Benutzername: {username} - Anzahl Versuche: {attempt_count} - Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')} - - Die IP-Adresse wurde für 24 Stunden gesperrt. - - Dies ist eine automatische Nachricht vom v2-Docker Admin Panel. - """ - - # TODO: E-Mail-Versand implementieren wenn SMTP konfiguriert - logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") - print(f"E-Mail würde gesendet: {subject}") - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = os.getenv('RECAPTCHA_SECRET_KEY') - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - -def generate_license_key(license_type='full'): - """ - Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ - - AF = Account Factory (Produktkennung) - F/T = F für Fullversion, T für Testversion - YYYY = Jahr - MM = Monat - XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen - """ - # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) - chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' - - # Datum-Teil - now = datetime.now(ZoneInfo("Europe/Berlin")) - date_part = now.strftime('%Y%m') - type_char = 'F' if license_type == 'full' else 'T' - - # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) - parts = [] - for _ in range(3): - part = ''.join(secrets.choice(chars) for _ in range(4)) - parts.append(part) - - # Key zusammensetzen - key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" - - return key - -def validate_license_key(key): - """ - Validiert das License Key Format - Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ - """ - if not key: - return False - - # Pattern für das neue Format - # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen - pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' - - # Großbuchstaben für Vergleich - return bool(re.match(pattern, key.upper())) - -@app.route("/login", methods=["GET", "POST"]) -def login(): - # Timing-Attack Schutz - Start Zeit merken - start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') - if attempt_count >= CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - if not captcha_response: - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA ERFORDERLICH!", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - user = get_user_by_username(username) - login_success = False - needs_2fa = False - - if user: - # Database user authentication - if verify_password(password, user['password_hash']): - login_success = True - needs_2fa = user['totp_enabled'] - else: - # Fallback to environment variables for backward compatibility - admin1_user = os.getenv("ADMIN1_USERNAME") - admin1_pass = os.getenv("ADMIN1_PASSWORD") - admin2_user = os.getenv("ADMIN2_USERNAME") - admin2_pass = os.getenv("ADMIN2_PASSWORD") - - if ((username == admin1_user and password == admin1_pass) or - (username == admin2_user and password == admin2_pass)): - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), - error_type="failed", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) - -@app.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('login')) - -@app.route("/verify-2fa", methods=["GET", "POST"]) -def verify_2fa(): - if not session.get('awaiting_2fa'): - return redirect(url_for('login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - conn.commit() - cur.close() - conn.close() - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('dashboard')) - - # Failed verification - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - conn.commit() - cur.close() - conn.close() - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - -@app.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -@app.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -@app.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -@app.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -@app.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -@app.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -@app.route("/api/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -@app.route("/api/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -@app.route("/") -@login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - 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() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -@app.route("/create", methods=["GET", "POST"]) -@login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -@app.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -@app.route("/batch/export") -@login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -@app.route("/licenses") -@login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/license/edit/", methods=["GET", "POST"]) -@login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -@app.route("/license/delete/", methods=["POST"]) -@login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -@app.route("/customers") -@login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/customer/edit/", methods=["GET", "POST"]) -@login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -@app.route("/customer/create", methods=["GET", "POST"]) -@login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -@app.route("/customer/delete/", methods=["POST"]) -@login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -@app.route("/customers-licenses") -@login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -@app.route("/api/customer//licenses") -@login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -@app.route("/api/customer//quick-stats") -@login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -@app.route("/api/license//quick-edit", methods=['POST']) -@login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/api/license//resources") -@login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/sessions") -@login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -@app.route("/session/end/", methods=["POST"]) -@login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -@app.route("/export/licenses") -@login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/audit") -@login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/customers") -@login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/sessions") -@login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/resources") -@login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/audit") -@login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - 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) - -@app.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -@app.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -@app.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -@app.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -@app.route("/api/license//toggle", methods=["POST"]) -@login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -@app.route("/api/license//register-device", methods=["POST"]) -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - try: - data = request.get_json() - hardware_id = data.get('hardware_id') - device_name = data.get('device_name', '') - operating_system = data.get('operating_system', '') - - if not hardware_id: - return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - cur.execute(""" - SELECT device_limit, is_active, valid_until - FROM licenses - WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - if not is_active: - return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_id = %s AND hardware_id = %s - """, (license_id, hardware_id)) - existing_device = cur.fetchone() - - if existing_device: - device_id, is_device_active = existing_device - if is_device_active: - # Gerät ist bereits aktiv, update last_seen - cur.execute(""" - UPDATE device_registrations - SET last_seen = CURRENT_TIMESTAMP, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = TRUE, - last_seen = CURRENT_TIMESTAMP, - deactivated_at = NULL, - deactivated_by = NULL, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (license_id, hardware_id, device_name, operating_system, - get_client_ip(), request.headers.get('User-Agent', ''))) - device_id = cur.fetchone()[0] - - conn.commit() - - # Audit Log - log_audit('DEVICE_REGISTER', 'device', device_id, - new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - except Exception as e: - logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -@app.route("/api/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -@app.route("/api/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -@app.route('/resources') -@login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -@app.route('/resources/add', methods=['GET', 'POST']) -@login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -@app.route('/resources/quarantine/', methods=['POST']) -@login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/resources/release', methods=['POST']) -@login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/api/resources/allocate', methods=['POST']) -@login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -@app.route('/api/resources/check-availability', methods=['GET']) -@login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -@app.route('/api/global-search', methods=['GET']) -@login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -@app.route('/resources/history/') -@login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -@app.route('/resources/metrics') -@login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -@app.route('/resources/report', methods=['GET']) -@login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) +import os +import time +import json +import logging +import requests +from io import BytesIO +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from pathlib import Path + +from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash +from flask_session import Session +from werkzeug.middleware.proxy_fix import ProxyFix +from apscheduler.schedulers.background import BackgroundScheduler +import pandas as pd +from psycopg2.extras import Json + +# Import our new modules +import config +from db import get_connection, get_db_connection, get_db_cursor, execute_query +from auth.decorators import login_required +from auth.password import hash_password, verify_password +from auth.two_factor import ( + generate_totp_secret, generate_qr_code, verify_totp, + generate_backup_codes, hash_backup_code, verify_backup_code +) +from auth.rate_limiting import ( + get_client_ip, check_ip_blocked, record_failed_attempt, + reset_login_attempts, get_login_attempts +) +from utils.audit import log_audit +from utils.license import generate_license_key, validate_license_key +from utils.backup import create_backup, restore_backup, get_or_create_encryption_key +from utils.export import ( + create_excel_export, format_datetime_for_export, + prepare_license_export_data, prepare_customer_export_data, + prepare_session_export_data, prepare_audit_export_data +) + +app = Flask(__name__) +# Load configuration from config module +app.config['SECRET_KEY'] = config.SECRET_KEY +app.config['SESSION_TYPE'] = config.SESSION_TYPE +app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII +app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE +app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME +app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY +app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE +app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE +app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME +app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST +Session(app) + +# ProxyFix für korrekte IP-Adressen hinter Nginx +app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 +) + +# Configuration is now loaded from config module + +# Scheduler für automatische Backups +scheduler = BackgroundScheduler() +scheduler.start() + +# Logging konfigurieren +logging.basicConfig(level=logging.INFO) + + +# Login decorator +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'logged_in' not in session: + return redirect(url_for('login')) + + # Prüfe ob Session abgelaufen ist + if 'last_activity' in session: + last_activity = datetime.fromisoformat(session['last_activity']) + time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity + + # Debug-Logging + app.logger.info(f"Session check for {session.get('username', 'unknown')}: " + f"Last activity: {last_activity}, " + f"Time since: {time_since_activity.total_seconds()} seconds") + + if time_since_activity > timedelta(minutes=5): + # Session abgelaufen - Logout + username = session.get('username', 'unbekannt') + app.logger.info(f"Session timeout for user {username} - auto logout") + # Audit-Log für automatischen Logout (vor session.clear()!) + try: + log_audit('AUTO_LOGOUT', 'session', additional_info={'reason': 'Session timeout (5 minutes)', 'username': username}) + except: + pass + session.clear() + flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning') + return redirect(url_for('login')) + + # Aktivität NICHT automatisch aktualisieren + # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) + return f(*args, **kwargs) + return decorated_function + +# DB-Verbindung mit UTF-8 Encoding +def get_connection(): + conn = psycopg2.connect( + host=os.getenv("POSTGRES_HOST", "postgres"), + port=os.getenv("POSTGRES_PORT", "5432"), + dbname=os.getenv("POSTGRES_DB"), + user=os.getenv("POSTGRES_USER"), + password=os.getenv("POSTGRES_PASSWORD"), + options='-c client_encoding=UTF8' + ) + conn.set_client_encoding('UTF8') + return conn + +# User Authentication Helper Functions +def hash_password(password): + """Hash a password using bcrypt""" + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + +def verify_password(password, hashed): + """Verify a password against its hash""" + return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) + +def get_user_by_username(username): + """Get user from database by username""" + conn = get_connection() + cur = conn.cursor() + try: + cur.execute(""" + SELECT id, username, password_hash, email, totp_secret, totp_enabled, + backup_codes, last_password_change, failed_2fa_attempts + FROM users WHERE username = %s + """, (username,)) + user = cur.fetchone() + if user: + return { + 'id': user[0], + 'username': user[1], + 'password_hash': user[2], + 'email': user[3], + 'totp_secret': user[4], + 'totp_enabled': user[5], + 'backup_codes': user[6], + 'last_password_change': user[7], + 'failed_2fa_attempts': user[8] + } + return None + finally: + cur.close() + conn.close() + +def generate_totp_secret(): + """Generate a new TOTP secret""" + return pyotp.random_base32() + +def generate_qr_code(username, totp_secret): + """Generate QR code for TOTP setup""" + totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( + name=username, + issuer_name='V2 Admin Panel' + ) + + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(totp_uri) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + buf = BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + + return base64.b64encode(buf.getvalue()).decode() + +def verify_totp(totp_secret, token): + """Verify a TOTP token""" + totp = pyotp.TOTP(totp_secret) + return totp.verify(token, valid_window=1) + +def generate_backup_codes(count=8): + """Generate backup codes for 2FA recovery""" + codes = [] + for _ in range(count): + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) + codes.append(code) + return codes + +def hash_backup_code(code): + """Hash a backup code for storage""" + return hashlib.sha256(code.encode()).hexdigest() + +def verify_backup_code(code, hashed_codes): + """Verify a backup code against stored hashes""" + code_hash = hashlib.sha256(code.encode()).hexdigest() + return code_hash in hashed_codes + +# Audit-Log-Funktion +def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): + """Protokolliert Änderungen im Audit-Log""" + conn = get_connection() + cur = conn.cursor() + + try: + username = session.get('username', 'system') + ip_address = get_client_ip() if request else None + user_agent = request.headers.get('User-Agent') if request else None + + # Debug logging + app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") + + # Konvertiere Dictionaries zu JSONB + old_json = Json(old_values) if old_values else None + new_json = Json(new_values) if new_values else None + + cur.execute(""" + INSERT INTO audit_log + (username, action, entity_type, entity_id, old_values, new_values, + ip_address, user_agent, additional_info) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (username, action, entity_type, entity_id, old_json, new_json, + ip_address, user_agent, additional_info)) + + conn.commit() + except Exception as e: + print(f"Audit log error: {e}") + conn.rollback() + finally: + 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(ZoneInfo("Europe/Berlin")).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 +) + +# Rate-Limiting Funktionen +def get_client_ip(): + """Ermittelt die echte IP-Adresse des Clients""" + # Debug logging + app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") + + # Try X-Real-IP first (set by nginx) + if request.headers.get('X-Real-IP'): + return request.headers.get('X-Real-IP') + # Then X-Forwarded-For + elif request.headers.get('X-Forwarded-For'): + # X-Forwarded-For can contain multiple IPs, take the first one + return request.headers.get('X-Forwarded-For').split(',')[0].strip() + # Fallback to remote_addr + else: + return request.remote_addr + +def check_ip_blocked(ip_address): + """Prüft ob eine IP-Adresse gesperrt ist""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT blocked_until FROM login_attempts + WHERE ip_address = %s AND blocked_until IS NOT NULL + """, (ip_address,)) + + result = cur.fetchone() + cur.close() + conn.close() + + if result and result[0]: + if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None): + return True, result[0] + return False, None + +def record_failed_attempt(ip_address, username): + """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" + conn = get_connection() + cur = conn.cursor() + + # Random Fehlermeldung + error_message = random.choice(FAIL_MESSAGES) + + try: + # Prüfen ob IP bereits existiert + cur.execute(""" + SELECT attempt_count FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + + result = cur.fetchone() + + if result: + # Update bestehenden Eintrag + new_count = result[0] + 1 + blocked_until = None + + if new_count >= MAX_LOGIN_ATTEMPTS: + blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS) + # E-Mail-Benachrichtigung (wenn aktiviert) + if os.getenv("EMAIL_ENABLED", "false").lower() == "true": + send_security_alert_email(ip_address, username, new_count) + + cur.execute(""" + UPDATE login_attempts + SET attempt_count = %s, + last_attempt = CURRENT_TIMESTAMP, + blocked_until = %s, + last_username_tried = %s, + last_error_message = %s + WHERE ip_address = %s + """, (new_count, blocked_until, username, error_message, ip_address)) + else: + # Neuen Eintrag erstellen + cur.execute(""" + INSERT INTO login_attempts + (ip_address, attempt_count, last_username_tried, last_error_message) + VALUES (%s, 1, %s, %s) + """, (ip_address, username, error_message)) + + conn.commit() + + # Audit-Log + log_audit('LOGIN_FAILED', 'user', + additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}") + + except Exception as e: + print(f"Rate limiting error: {e}") + conn.rollback() + finally: + cur.close() + conn.close() + + return error_message + +def reset_login_attempts(ip_address): + """Setzt die Login-Versuche für eine IP zurück""" + conn = get_connection() + cur = conn.cursor() + + try: + cur.execute(""" + DELETE FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + conn.commit() + except Exception as e: + print(f"Reset attempts error: {e}") + conn.rollback() + finally: + cur.close() + conn.close() + +def get_login_attempts(ip_address): + """Gibt die Anzahl der Login-Versuche für eine IP zurück""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT attempt_count FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + + result = cur.fetchone() + cur.close() + conn.close() + + return result[0] if result else 0 + +def send_security_alert_email(ip_address, username, attempt_count): + """Sendet eine Sicherheitswarnung per E-Mail""" + subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche" + body = f""" + WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt! + + IP-Adresse: {ip_address} + Versuchter Benutzername: {username} + Anzahl Versuche: {attempt_count} + Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')} + + Die IP-Adresse wurde für 24 Stunden gesperrt. + + Dies ist eine automatische Nachricht vom v2-Docker Admin Panel. + """ + + # TODO: E-Mail-Versand implementieren wenn SMTP konfiguriert + logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") + print(f"E-Mail würde gesendet: {subject}") + +def verify_recaptcha(response): + """Verifiziert die reCAPTCHA v2 Response mit Google""" + secret_key = os.getenv('RECAPTCHA_SECRET_KEY') + + # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) + if not secret_key: + logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") + return True + + # Verifizierung bei Google + try: + verify_url = 'https://www.google.com/recaptcha/api/siteverify' + data = { + 'secret': secret_key, + 'response': response + } + + # Timeout für Request setzen + r = requests.post(verify_url, data=data, timeout=5) + result = r.json() + + # Log für Debugging + if not result.get('success'): + logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") + + return result.get('success', False) + + except requests.exceptions.RequestException as e: + logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") + # Bei Netzwerkfehlern CAPTCHA als bestanden werten + return True + except Exception as e: + logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") + return False + +def generate_license_key(license_type='full'): + """ + Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ + + AF = Account Factory (Produktkennung) + F/T = F für Fullversion, T für Testversion + YYYY = Jahr + MM = Monat + XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen + """ + # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) + chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + + # Datum-Teil + now = datetime.now(ZoneInfo("Europe/Berlin")) + date_part = now.strftime('%Y%m') + type_char = 'F' if license_type == 'full' else 'T' + + # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) + parts = [] + for _ in range(3): + part = ''.join(secrets.choice(chars) for _ in range(4)) + parts.append(part) + + # Key zusammensetzen + key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" + + return key + +def validate_license_key(key): + """ + Validiert das License Key Format + Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ + """ + if not key: + return False + + # Pattern für das neue Format + # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen + pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' + + # Großbuchstaben für Vergleich + return bool(re.match(pattern, key.upper())) + +@app.route("/login", methods=["GET", "POST"]) +def login(): + # Timing-Attack Schutz - Start Zeit merken + start_time = time.time() + + # IP-Adresse ermitteln + ip_address = get_client_ip() + + # Prüfen ob IP gesperrt ist + is_blocked, blocked_until = check_ip_blocked(ip_address) + if is_blocked: + time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 + error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." + return render_template("login.html", error=error_msg, error_type="blocked") + + # Anzahl bisheriger Versuche + attempt_count = get_login_attempts(ip_address) + + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + captcha_response = request.form.get("g-recaptcha-response") + + # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind + recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') + if attempt_count >= CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: + if not captcha_response: + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA ERFORDERLICH!", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # CAPTCHA validieren + if not verify_recaptcha(captcha_response): + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # Check user in database first, fallback to env vars + user = get_user_by_username(username) + login_success = False + needs_2fa = False + + if user: + # Database user authentication + if verify_password(password, user['password_hash']): + login_success = True + needs_2fa = user['totp_enabled'] + else: + # Fallback to environment variables for backward compatibility + admin1_user = os.getenv("ADMIN1_USERNAME") + admin1_pass = os.getenv("ADMIN1_PASSWORD") + admin2_user = os.getenv("ADMIN2_USERNAME") + admin2_pass = os.getenv("ADMIN2_PASSWORD") + + if ((username == admin1_user and password == admin1_pass) or + (username == admin2_user and password == admin2_pass)): + login_success = True + + # Timing-Attack Schutz - Mindestens 1 Sekunde warten + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + + if login_success: + # Erfolgreicher Login + if needs_2fa: + # Store temporary session for 2FA verification + session['temp_username'] = username + session['temp_user_id'] = user['id'] + session['awaiting_2fa'] = True + return redirect(url_for('verify_2fa')) + else: + # Complete login without 2FA + session.permanent = True # Aktiviert das Timeout + session['logged_in'] = True + session['username'] = username + session['user_id'] = user['id'] if user else None + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + reset_login_attempts(ip_address) + log_audit('LOGIN_SUCCESS', 'user', + additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") + return redirect(url_for('dashboard')) + else: + # Fehlgeschlagener Login + error_message = record_failed_attempt(ip_address, username) + new_attempt_count = get_login_attempts(ip_address) + + # Prüfen ob jetzt gesperrt + is_now_blocked, _ = check_ip_blocked(ip_address) + if is_now_blocked: + log_audit('LOGIN_BLOCKED', 'security', + additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") + + return render_template("login.html", + error=error_message, + show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), + error_type="failed", + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), + recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) + + # GET Request + return render_template("login.html", + show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), + attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) + +@app.route("/logout") +def logout(): + username = session.get('username', 'unknown') + log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") + session.pop('logged_in', None) + session.pop('username', None) + session.pop('user_id', None) + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + return redirect(url_for('login')) + +@app.route("/verify-2fa", methods=["GET", "POST"]) +def verify_2fa(): + if not session.get('awaiting_2fa'): + return redirect(url_for('login')) + + if request.method == "POST": + token = request.form.get('token', '').replace(' ', '') + username = session.get('temp_username') + user_id = session.get('temp_user_id') + + if not username or not user_id: + flash('Session expired. Please login again.', 'error') + return redirect(url_for('login')) + + user = get_user_by_username(username) + if not user: + flash('User not found.', 'error') + return redirect(url_for('login')) + + # Check if it's a backup code + if len(token) == 8 and token.isupper(): + # Try backup code + backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] + if verify_backup_code(token, backup_codes): + # Remove used backup code + code_hash = hash_backup_code(token) + backup_codes.remove(code_hash) + + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", + (json.dumps(backup_codes), user_id)) + conn.commit() + cur.close() + conn.close() + + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + flash('Login successful using backup code. Please generate new backup codes.', 'warning') + log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") + return redirect(url_for('dashboard')) + else: + # Try TOTP token + if verify_totp(user['totp_secret'], token): + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") + return redirect(url_for('dashboard')) + + # Failed verification + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", + (datetime.now(), user_id)) + conn.commit() + cur.close() + conn.close() + + flash('Invalid authentication code. Please try again.', 'error') + log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") + + return render_template('verify_2fa.html') + +@app.route("/profile") +@login_required +def profile(): + user = get_user_by_username(session['username']) + if not user: + # For environment-based users, redirect with message + flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') + return redirect(url_for('dashboard')) + return render_template('profile.html', user=user) + +@app.route("/profile/change-password", methods=["POST"]) +@login_required +def change_password(): + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + user = get_user_by_username(session['username']) + + # Verify current password + if not verify_password(current_password, user['password_hash']): + flash('Current password is incorrect.', 'error') + return redirect(url_for('profile')) + + # Check new password + if new_password != confirm_password: + flash('New passwords do not match.', 'error') + return redirect(url_for('profile')) + + if len(new_password) < 8: + flash('Password must be at least 8 characters long.', 'error') + return redirect(url_for('profile')) + + # Update password + new_hash = hash_password(new_password) + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", + (new_hash, datetime.now(), user['id'])) + conn.commit() + cur.close() + conn.close() + + log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], + additional_info="Password changed successfully") + flash('Password changed successfully.', 'success') + return redirect(url_for('profile')) + +@app.route("/profile/setup-2fa") +@login_required +def setup_2fa(): + user = get_user_by_username(session['username']) + + if user['totp_enabled']: + flash('2FA is already enabled for your account.', 'info') + return redirect(url_for('profile')) + + # Generate new TOTP secret + totp_secret = generate_totp_secret() + session['temp_totp_secret'] = totp_secret + + # Generate QR code + qr_code = generate_qr_code(user['username'], totp_secret) + + return render_template('setup_2fa.html', + totp_secret=totp_secret, + qr_code=qr_code) + +@app.route("/profile/enable-2fa", methods=["POST"]) +@login_required +def enable_2fa(): + token = request.form.get('token', '').replace(' ', '') + totp_secret = session.get('temp_totp_secret') + + if not totp_secret: + flash('2FA setup session expired. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Verify the token + if not verify_totp(totp_secret, token): + flash('Invalid authentication code. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Generate backup codes + backup_codes = generate_backup_codes() + hashed_codes = [hash_backup_code(code) for code in backup_codes] + + # Enable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s + WHERE username = %s + """, (totp_secret, json.dumps(hashed_codes), session['username'])) + conn.commit() + cur.close() + conn.close() + + session.pop('temp_totp_secret', None) + + log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") + + # Show backup codes + return render_template('backup_codes.html', backup_codes=backup_codes) + +@app.route("/profile/disable-2fa", methods=["POST"]) +@login_required +def disable_2fa(): + password = request.form.get('password') + user = get_user_by_username(session['username']) + + # Verify password + if not verify_password(password, user['password_hash']): + flash('Incorrect password.', 'error') + return redirect(url_for('profile')) + + # Disable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL + WHERE username = %s + """, (session['username'],)) + conn.commit() + cur.close() + conn.close() + + log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") + flash('2FA has been disabled for your account.', 'success') + return redirect(url_for('profile')) + +@app.route("/heartbeat", methods=['POST']) +@login_required +def heartbeat(): + """Endpoint für Session Keep-Alive - aktualisiert last_activity""" + # Aktualisiere last_activity nur wenn explizit angefordert + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + # Force session save + session.modified = True + + return jsonify({ + 'status': 'ok', + 'last_activity': session['last_activity'], + 'username': session.get('username') + }) + +@app.route("/api/generate-license-key", methods=['POST']) +@login_required +def api_generate_key(): + """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" + try: + # Lizenztyp aus Request holen (default: full) + data = request.get_json() or {} + license_type = data.get('type', 'full') + + # Key generieren + key = generate_license_key(license_type) + + # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) + conn = get_connection() + cur = conn.cursor() + + # Wiederhole bis eindeutiger Key gefunden + attempts = 0 + while attempts < 10: # Max 10 Versuche + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) + if not cur.fetchone(): + break # Key ist eindeutig + key = generate_license_key(license_type) + attempts += 1 + + cur.close() + conn.close() + + # Log für Audit + log_audit('GENERATE_KEY', 'license', + additional_info={'type': license_type, 'key': key}) + + return jsonify({ + 'success': True, + 'key': key, + 'type': license_type + }) + + except Exception as e: + logging.error(f"Fehler bei Key-Generierung: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Fehler bei der Key-Generierung' + }), 500 + +@app.route("/api/customers", methods=['GET']) +@login_required +def api_customers(): + """API Endpoint für die Kundensuche mit Select2""" + try: + # Suchparameter + search = request.args.get('q', '').strip() + page = request.args.get('page', 1, type=int) + per_page = 20 + customer_id = request.args.get('id', type=int) + + conn = get_connection() + cur = conn.cursor() + + # Einzelnen Kunden per ID abrufen + if customer_id: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.id = %s + GROUP BY c.id, c.name, c.email + """, (customer_id,)) + + customer = cur.fetchone() + results = [] + if customer: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} ({customer[2]})", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + cur.close() + conn.close() + + return jsonify({ + 'results': results, + 'pagination': {'more': False} + }) + + # SQL Query mit optionaler Suche + elif search: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE LOWER(c.name) LIKE LOWER(%s) + OR LOWER(c.email) LIKE LOWER(%s) + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) + else: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (per_page, (page - 1) * per_page)) + + customers = cur.fetchall() + + # Format für Select2 + results = [] + for customer in customers: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + # Gesamtanzahl für Pagination + if search: + cur.execute(""" + SELECT COUNT(*) FROM customers + WHERE LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + """, (f'%{search}%', f'%{search}%')) + else: + cur.execute("SELECT COUNT(*) FROM customers") + + total_count = cur.fetchone()[0] + + cur.close() + conn.close() + + # Select2 Response Format + return jsonify({ + 'results': results, + 'pagination': { + 'more': (page * per_page) < total_count + } + }) + + except Exception as e: + logging.error(f"Fehler bei Kundensuche: {str(e)}") + return jsonify({ + 'results': [], + 'error': 'Fehler bei der Kundensuche' + }), 500 + +@app.route("/") +@login_required +def dashboard(): + conn = get_connection() + cur = conn.cursor() + + # Statistiken abrufen + # Gesamtanzahl Kunden (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") + total_customers = cur.fetchone()[0] + + # Gesamtanzahl Lizenzen (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") + total_licenses = cur.fetchone()[0] + + # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE + """) + active_licenses = cur.fetchone()[0] + + # Aktive Sessions + cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") + active_sessions_count = cur.fetchone()[0] + + # Abgelaufene Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until < CURRENT_DATE AND is_test = FALSE + """) + expired_licenses = cur.fetchone()[0] + + # Deaktivierte Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE is_active = FALSE AND is_test = FALSE + """) + inactive_licenses = cur.fetchone()[0] + + # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE + AND valid_until < CURRENT_DATE + INTERVAL '30 days' + AND is_active = TRUE + AND is_test = FALSE + """) + expiring_soon = cur.fetchone()[0] + + # Testlizenzen vs Vollversionen (ohne Testdaten) + cur.execute(""" + SELECT license_type, COUNT(*) + FROM licenses + WHERE is_test = FALSE + GROUP BY license_type + """) + license_types = dict(cur.fetchall()) + + # Anzahl Testdaten + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") + test_data_count = cur.fetchone()[0] + + # Anzahl Test-Kunden + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") + test_customers_count = cur.fetchone()[0] + + # Anzahl Test-Ressourcen + cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") + test_resources_count = cur.fetchone()[0] + + # Letzte 5 erstellten Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.is_test = FALSE + ORDER BY l.id DESC + LIMIT 5 + """) + recent_licenses = cur.fetchall() + + # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + l.valid_until - CURRENT_DATE as days_left + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.valid_until >= CURRENT_DATE + AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' + AND l.is_active = TRUE + AND l.is_test = FALSE + ORDER BY l.valid_until + LIMIT 10 + """) + 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() + + # Sicherheitsstatistiken + # Gesperrte IPs + cur.execute(""" + SELECT COUNT(*) FROM login_attempts + WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP + """) + blocked_ips_count = cur.fetchone()[0] + + # Fehlversuche heute + cur.execute(""" + SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts + WHERE last_attempt::date = CURRENT_DATE + """) + failed_attempts_today = cur.fetchone()[0] + + # Letzte 5 Sicherheitsereignisse + cur.execute(""" + SELECT + la.ip_address, + la.attempt_count, + la.last_attempt, + la.blocked_until, + la.last_username_tried, + la.last_error_message + FROM login_attempts la + ORDER BY la.last_attempt DESC + LIMIT 5 + """) + recent_security_events = [] + for event in cur.fetchall(): + recent_security_events.append({ + 'ip_address': event[0], + 'attempt_count': event[1], + 'last_attempt': event[2].strftime('%d.%m %H:%M'), + 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, + 'username_tried': event[4], + 'error_message': event[5] + }) + + # Sicherheitslevel berechnen + if blocked_ips_count > 5 or failed_attempts_today > 50: + security_level = 'danger' + security_level_text = 'KRITISCH' + elif blocked_ips_count > 2 or failed_attempts_today > 20: + security_level = 'warning' + security_level_text = 'ERHÖHT' + else: + security_level = 'success' + security_level_text = 'NORMAL' + + # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = FALSE + GROUP BY resource_type + """) + + resource_stats = {} + resource_warning = None + + for row in cur.fetchall(): + available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + resource_stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': available_percent, + 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' + } + + # Warnung bei niedrigem Bestand + if row[1] < 50: + if not resource_warning: + resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" + else: + resource_warning += f" | {row[0].upper()}: {row[1]}" + + cur.close() + conn.close() + + stats = { + 'total_customers': total_customers, + 'total_licenses': total_licenses, + 'active_licenses': active_licenses, + 'expired_licenses': expired_licenses, + 'inactive_licenses': inactive_licenses, + 'expiring_soon': expiring_soon, + 'full_licenses': license_types.get('full', 0), + 'test_licenses': license_types.get('test', 0), + 'test_data_count': test_data_count, + 'test_customers_count': test_customers_count, + 'test_resources_count': test_resources_count, + 'recent_licenses': recent_licenses, + 'expiring_licenses': expiring_licenses, + 'active_sessions': active_sessions_count, + 'last_backup': last_backup_info, + # Sicherheitsstatistiken + 'blocked_ips_count': blocked_ips_count, + 'failed_attempts_today': failed_attempts_today, + 'recent_security_events': recent_security_events, + 'security_level': security_level, + 'security_level_text': security_level_text, + 'resource_stats': resource_stats + } + + return render_template("dashboard.html", + stats=stats, + resource_stats=resource_stats, + resource_warning=resource_warning, + username=session.get('username')) + +@app.route("/create", methods=["GET", "POST"]) +@login_required +def create_license(): + if request.method == "POST": + customer_id = request.form.get("customer_id") + license_key = request.form["license_key"].upper() # Immer Großbuchstaben + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Validiere License Key Format + if not validate_license_key(license_key): + flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') + return redirect(url_for('create_license')) + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('create_license')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('create_license')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + customer_info = {'name': name, 'email': email, 'is_test': is_test} + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos für Audit-Log + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('create_license')) + customer_info = {'name': customer_data[0], 'email': customer_data[1]} + + # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Lizenz hinzufügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit, is_test) + VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit, is_test)) + license_id = cur.fetchone()[0] + + # Ressourcen zuweisen + try: + # Prüfe Verfügbarkeit + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") + if available[1] < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") + if available[2] < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") + + # Domains zuweisen + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s zuweisen + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern zuweisen + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + except ValueError as e: + conn.rollback() + flash(str(e), 'error') + return redirect(url_for('create_license')) + + conn.commit() + + # Audit-Log + log_audit('CREATE', 'license', license_id, + new_values={ + 'license_key': license_key, + 'customer_name': customer_info['name'], + 'customer_email': customer_info['email'], + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'device_limit': device_limit, + 'is_test': is_test + }) + + flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') + + except Exception as e: + conn.rollback() + logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") + flash('Fehler beim Erstellen der Lizenz!', 'error') + finally: + cur.close() + conn.close() + + # Preserve show_test parameter if present + redirect_url = "/create" + if request.args.get('show_test') == 'true': + redirect_url += "?show_test=true" + return redirect(redirect_url) + + # Unterstützung für vorausgewählten Kunden + preselected_customer_id = request.args.get('customer_id', type=int) + return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) + +@app.route("/batch", methods=["GET", "POST"]) +@login_required +def batch_licenses(): + """Batch-Generierung mehrerer Lizenzen für einen Kunden""" + if request.method == "POST": + # Formulardaten + customer_id = request.form.get("customer_id") + license_type = request.form["license_type"] + quantity = int(request.form["quantity"]) + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + # Sicherheitslimit + if quantity < 1 or quantity > 100: + flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') + return redirect(url_for('batch_licenses')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('batch_licenses')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('batch_licenses')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('batch_licenses')) + name = customer_data[0] + email = customer_data[1] + + # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch + total_domains_needed = domain_count * quantity + total_ipv4s_needed = ipv4_count * quantity + total_phones_needed = phone_count * quantity + + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < total_domains_needed: + flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') + return redirect(url_for('batch_licenses')) + if available[1] < total_ipv4s_needed: + flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') + return redirect(url_for('batch_licenses')) + if available[2] < total_phones_needed: + flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') + return redirect(url_for('batch_licenses')) + + # Lizenzen generieren und speichern + generated_licenses = [] + for i in range(quantity): + # Eindeutigen Key generieren + attempts = 0 + while attempts < 10: + license_key = generate_license_key(license_type) + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) + if not cur.fetchone(): + break + attempts += 1 + + # Lizenz einfügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, is_test, + valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit) + VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit)) + license_id = cur.fetchone()[0] + + # Ressourcen für diese Lizenz zuweisen + # Domains + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + generated_licenses.append({ + 'id': license_id, + 'key': license_key, + 'type': license_type + }) + + conn.commit() + + # Audit-Log + log_audit('CREATE_BATCH', 'license', + new_values={'customer': name, 'quantity': quantity, 'type': license_type}, + additional_info=f"Batch-Generierung von {quantity} Lizenzen") + + # Session für Export speichern + session['batch_export'] = { + 'customer': name, + 'email': email, + 'licenses': generated_licenses, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + } + + flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') + return render_template("batch_result.html", + customer=name, + email=email, + licenses=generated_licenses, + valid_from=valid_from, + valid_until=valid_until) + + except Exception as e: + conn.rollback() + logging.error(f"Fehler bei Batch-Generierung: {str(e)}") + flash('Fehler bei der Batch-Generierung!', 'error') + return redirect(url_for('batch_licenses')) + finally: + cur.close() + conn.close() + + # GET Request + return render_template("batch_form.html") + +@app.route("/batch/export") +@login_required +def export_batch(): + """Exportiert die zuletzt generierten Batch-Lizenzen""" + batch_data = session.get('batch_export') + if not batch_data: + flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') + return redirect(url_for('batch_licenses')) + + # CSV generieren + output = io.StringIO() + output.write('\ufeff') # UTF-8 BOM für Excel + + # Header + output.write(f"Kunde: {batch_data['customer']}\n") + output.write(f"E-Mail: {batch_data['email']}\n") + output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") + output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") + output.write("\n") + output.write("Nr;Lizenzschlüssel;Typ\n") + + # Lizenzen + for i, license in enumerate(batch_data['licenses'], 1): + typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" + output.write(f"{i};{license['key']};{typ_text}\n") + + output.seek(0) + + # Audit-Log + log_audit('EXPORT', 'batch_licenses', + additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" + ) + +@app.route("/licenses") +@login_required +def licenses(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/license/edit/", methods=["GET", "POST"]) +@login_required +def edit_license(license_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute(""" + SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit + FROM licenses WHERE id = %s + """, (license_id,)) + old_license = cur.fetchone() + + # Update license + license_key = request.form["license_key"] + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + valid_until = request.form["valid_until"] + is_active = request.form.get("is_active") == "on" + is_test = request.form.get("is_test") == "on" + device_limit = int(request.form.get("device_limit", 3)) + + cur.execute(""" + UPDATE licenses + SET license_key = %s, license_type = %s, valid_from = %s, + valid_until = %s, is_active = %s, is_test = %s, device_limit = %s + WHERE id = %s + """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'license_key': old_license[0], + 'license_type': old_license[1], + 'valid_from': str(old_license[2]), + 'valid_until': str(old_license[3]), + 'is_active': old_license[4], + 'is_test': old_license[5], + 'device_limit': old_license[6] + }, + new_values={ + 'license_key': license_key, + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'is_active': is_active, + 'is_test': is_test, + 'device_limit': device_limit + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei wenn vorhanden + if request.referrer and 'customer_id=' in request.referrer: + import re + match = re.search(r'customer_id=(\d+)', request.referrer) + if match: + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={match.group(1)}" + + return redirect(redirect_url) + + # Get license data + cur.execute(""" + SELECT l.id, l.license_key, c.name, c.email, l.license_type, + l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + + license = cur.fetchone() + cur.close() + conn.close() + + if not license: + return redirect("/licenses") + + return render_template("edit_license.html", license=license, username=session.get('username')) + +@app.route("/license/delete/", methods=["POST"]) +@login_required +def delete_license(license_id): + conn = get_connection() + cur = conn.cursor() + + # Lizenzdetails für Audit-Log abrufen + cur.execute(""" + SELECT l.license_key, c.name, l.license_type + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + license_info = cur.fetchone() + + cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) + + conn.commit() + + # Audit-Log + if license_info: + log_audit('DELETE', 'license', license_id, + old_values={ + 'license_key': license_info[0], + 'customer_name': license_info[1], + 'license_type': license_info[2] + }) + + cur.close() + conn.close() + + return redirect("/licenses") + +@app.route("/customers") +@login_required +def customers(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/customer/edit/", methods=["GET", "POST"]) +@login_required +def edit_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + old_customer = cur.fetchone() + + # Update customer + name = request.form["name"] + email = request.form["email"] + is_test = request.form.get("is_test") == "on" + + cur.execute(""" + UPDATE customers + SET name = %s, email = %s, is_test = %s + WHERE id = %s + """, (name, email, is_test, customer_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'customer', customer_id, + old_values={ + 'name': old_customer[0], + 'email': old_customer[1], + 'is_test': old_customer[2] + }, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei (immer der aktuelle Kunde) + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={customer_id}" + + return redirect(redirect_url) + + # Get customer data with licenses + cur.execute(""" + SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s + """, (customer_id,)) + + customer = cur.fetchone() + if not customer: + cur.close() + conn.close() + return "Kunde nicht gefunden", 404 + + + # Get customer's licenses + cur.execute(""" + SELECT id, license_key, license_type, valid_from, valid_until, is_active + FROM licenses + WHERE customer_id = %s + ORDER BY valid_until DESC + """, (customer_id,)) + + licenses = cur.fetchall() + + cur.close() + conn.close() + + if not customer: + return redirect("/customers-licenses") + + return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) + +@app.route("/customer/create", methods=["GET", "POST"]) +@login_required +def create_customer(): + """Erstellt einen neuen Kunden ohne Lizenz""" + if request.method == "POST": + name = request.form.get('name') + email = request.form.get('email') + is_test = request.form.get('is_test') == 'on' + + if not name or not email: + flash("Name und E-Mail sind Pflichtfelder!", "error") + return render_template("create_customer.html", username=session.get('username')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfen ob E-Mail bereits existiert + cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) + existing = cur.fetchone() + if existing: + flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") + return render_template("create_customer.html", username=session.get('username')) + + # Kunde erstellen + cur.execute(""" + INSERT INTO customers (name, email, created_at, is_test) + VALUES (%s, %s, %s, %s) RETURNING id + """, (name, email, datetime.now(), is_test)) + + customer_id = cur.fetchone()[0] + conn.commit() + + # Audit-Log + log_audit('CREATE', 'customer', customer_id, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") + return redirect(f"/customer/edit/{customer_id}") + + except Exception as e: + conn.rollback() + flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") + return render_template("create_customer.html", username=session.get('username')) + finally: + cur.close() + conn.close() + + # GET Request - Formular anzeigen + return render_template("create_customer.html", username=session.get('username')) + +@app.route("/customer/delete/", methods=["POST"]) +@login_required +def delete_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + # Prüfen ob Kunde Lizenzen hat + cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) + license_count = cur.fetchone()[0] + + if license_count > 0: + # Kunde hat Lizenzen - nicht löschen + cur.close() + conn.close() + return redirect("/customers") + + # Kundendetails für Audit-Log abrufen + cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) + customer_info = cur.fetchone() + + # Kunde löschen wenn keine Lizenzen vorhanden + cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) + + conn.commit() + + # Audit-Log + if customer_info: + log_audit('DELETE', 'customer', customer_id, + old_values={ + 'name': customer_info[0], + 'email': customer_info[1] + }) + + cur.close() + conn.close() + + return redirect("/customers") + +@app.route("/customers-licenses") +@login_required +def customers_licenses(): + """Kombinierte Ansicht für Kunden und deren Lizenzen""" + conn = get_connection() + cur = conn.cursor() + + # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + query = """ + SELECT + c.id, + c.name, + c.email, + c.created_at, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + """ + + if not show_test: + query += " WHERE c.is_test = FALSE" + + query += """ + GROUP BY c.id, c.name, c.email, c.created_at + ORDER BY c.name + """ + + cur.execute(query) + customers = cur.fetchall() + + # Hole ausgewählten Kunden nur wenn explizit in URL angegeben + selected_customer_id = request.args.get('customer_id', type=int) + licenses = [] + selected_customer = None + + if customers and selected_customer_id: + # Hole Daten des ausgewählten Kunden + for customer in customers: + if customer[0] == selected_customer_id: + selected_customer = customer + break + + # Hole Lizenzen des ausgewählten Kunden + if selected_customer: + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (selected_customer_id,)) + licenses = cur.fetchall() + + cur.close() + conn.close() + + return render_template("customers_licenses.html", + customers=customers, + selected_customer=selected_customer, + selected_customer_id=selected_customer_id, + licenses=licenses, + show_test=show_test) + +@app.route("/api/customer//licenses") +@login_required +def api_customer_licenses(customer_id): + """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Lizenzen des Kunden + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (customer_id,)) + + licenses = [] + for row in cur.fetchall(): + license_id = row[0] + + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for res_row in cur.fetchall(): + resource_info = { + 'id': res_row[0], + 'value': res_row[2], + 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' + } + + if res_row[1] == 'domain': + resources['domains'].append(resource_info) + elif res_row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif res_row[1] == 'phone': + resources['phones'].append(resource_info) + + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'license_type': row[2], + 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', + 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', + 'is_active': row[5], + 'status': row[6], + 'domain_count': row[7], # limit + 'ipv4_count': row[8], # limit + 'phone_count': row[9], # limit + 'device_limit': row[10], + 'active_devices': row[11], + 'actual_domain_count': row[12], # actual count + 'actual_ipv4_count': row[13], # actual count + 'actual_phone_count': row[14], # actual count + 'resources': resources + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'licenses': licenses, + 'count': len(licenses) + }) + +@app.route("/api/customer//quick-stats") +@login_required +def api_customer_quick_stats(customer_id): + """API-Endpoint für Schnellstatistiken eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Kundenstatistiken + cur.execute(""" + SELECT + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon + FROM licenses l + WHERE l.customer_id = %s + """, (customer_id,)) + + stats = cur.fetchone() + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'stats': { + 'total': stats[0], + 'active': stats[1], + 'expired': stats[2], + 'expiring_soon': stats[3] + } + }) + +@app.route("/api/license//quick-edit", methods=['POST']) +@login_required +def api_license_quick_edit(license_id): + """API-Endpoint für schnelle Lizenz-Bearbeitung""" + conn = get_connection() + cur = conn.cursor() + + try: + data = request.get_json() + + # Hole alte Werte für Audit-Log + cur.execute(""" + SELECT is_active, valid_until, license_type + FROM licenses WHERE id = %s + """, (license_id,)) + old_values = cur.fetchone() + + if not old_values: + return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 + + # Update-Felder vorbereiten + updates = [] + params = [] + new_values = {} + + if 'is_active' in data: + updates.append("is_active = %s") + params.append(data['is_active']) + new_values['is_active'] = data['is_active'] + + if 'valid_until' in data: + updates.append("valid_until = %s") + params.append(data['valid_until']) + new_values['valid_until'] = data['valid_until'] + + if 'license_type' in data: + updates.append("license_type = %s") + params.append(data['license_type']) + new_values['license_type'] = data['license_type'] + + if updates: + params.append(license_id) + cur.execute(f""" + UPDATE licenses + SET {', '.join(updates)} + WHERE id = %s + """, params) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'is_active': old_values[0], + 'valid_until': old_values[1].isoformat() if old_values[1] else None, + 'license_type': old_values[2] + }, + new_values=new_values) + + cur.close() + conn.close() + + return jsonify({'success': True}) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/api/license//resources") +@login_required +def api_license_resources(license_id): + """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" + conn = get_connection() + cur = conn.cursor() + + try: + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for row in cur.fetchall(): + resource_info = { + 'id': row[0], + 'value': row[2], + 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' + } + + if row[1] == 'domain': + resources['domains'].append(resource_info) + elif row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif row[1] == 'phone': + resources['phones'].append(resource_info) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'resources': resources + }) + + except Exception as e: + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/sessions") +@login_required +def sessions(): + conn = get_connection() + cur = conn.cursor() + + # Sortierparameter + active_sort = request.args.get('active_sort', 'last_heartbeat') + active_order = request.args.get('active_order', 'desc') + ended_sort = request.args.get('ended_sort', 'ended_at') + ended_order = request.args.get('ended_order', 'desc') + + # Whitelist für erlaubte Sortierfelder - Aktive Sessions + active_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'last_heartbeat': 's.last_heartbeat', + 'inactive': 'minutes_inactive' + } + + # Whitelist für erlaubte Sortierfelder - Beendete Sessions + ended_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'ended_at': 's.ended_at', + 'duration': 'duration_minutes' + } + + # Validierung + if active_sort not in active_sort_fields: + active_sort = 'last_heartbeat' + if ended_sort not in ended_sort_fields: + ended_sort = 'ended_at' + if active_order not in ['asc', 'desc']: + active_order = 'desc' + if ended_order not in ['asc', 'desc']: + ended_order = 'desc' + + # Aktive Sessions abrufen + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.user_agent, s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = TRUE + ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} + """) + active_sessions = cur.fetchall() + + # Inaktive Sessions der letzten 24 Stunden + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = FALSE + AND s.ended_at > NOW() - INTERVAL '24 hours' + ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} + LIMIT 50 + """) + recent_sessions = cur.fetchall() + + cur.close() + conn.close() + + return render_template("sessions.html", + active_sessions=active_sessions, + recent_sessions=recent_sessions, + active_sort=active_sort, + active_order=active_order, + ended_sort=ended_sort, + ended_order=ended_order, + username=session.get('username')) + +@app.route("/session/end/", methods=["POST"]) +@login_required +def end_session(session_id): + conn = get_connection() + cur = conn.cursor() + + # Session beenden + cur.execute(""" + UPDATE sessions + SET is_active = FALSE, ended_at = NOW() + WHERE id = %s AND is_active = TRUE + """, (session_id,)) + + conn.commit() + cur.close() + conn.close() + + return redirect("/sessions") + +@app.route("/export/licenses") +@login_required +def export_licenses(): + conn = get_connection() + cur = conn.cursor() + + # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) + include_test = request.args.get('include_test', 'false').lower() == 'true' + customer_id = request.args.get('customer_id', type=int) + + query = """ + SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, + CASE + WHEN l.is_active = FALSE THEN 'Deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' + ELSE 'Aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + """ + + # Build WHERE clause + where_conditions = [] + params = [] + + if not include_test: + where_conditions.append("l.is_test = FALSE") + + if customer_id: + where_conditions.append("l.customer_id = %s") + params.append(customer_id) + + if where_conditions: + query += " WHERE " + " AND ".join(where_conditions) + + query += " ORDER BY l.id" + + cur.execute(query, params) + + # Spaltennamen + columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', + 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') + df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') + + # Typ und Aktiv Status anpassen + df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) + df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'license', + additional_info=f"Export aller Lizenzen als {export_format.upper()}") + filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Lizenzen', index=False) + + # Formatierung + worksheet = writer.sheets['Lizenzen'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/audit") +@login_required +def export_audit(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_user = request.args.get('user', '') + filter_action = request.args.get('action', '') + filter_entity = request.args.get('entity', '') + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + params = [] + + if filter_user: + query += " AND username ILIKE %s" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + query += " ORDER BY timestamp DESC" + + cur.execute(query, params) + audit_logs = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for log in audit_logs: + action_text = { + 'CREATE': 'Erstellt', + 'UPDATE': 'Bearbeitet', + 'DELETE': 'Gelöscht', + 'LOGIN': 'Anmeldung', + 'LOGOUT': 'Abmeldung', + 'AUTO_LOGOUT': 'Auto-Logout', + 'EXPORT': 'Export', + 'GENERATE_KEY': 'Key generiert', + 'CREATE_BATCH': 'Batch erstellt', + 'BACKUP': 'Backup erstellt', + 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', + 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', + 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', + 'LOGIN_BLOCKED': 'Login-Blockiert', + 'RESTORE': 'Wiederhergestellt', + 'PASSWORD_CHANGE': 'Passwort geändert', + '2FA_ENABLED': '2FA aktiviert', + '2FA_DISABLED': '2FA deaktiviert' + }.get(log[3], log[3]) + + data.append({ + 'ID': log[0], + 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), + 'Benutzer': log[2], + 'Aktion': action_text, + 'Entität': log[4], + 'Entität-ID': log[5] or '', + 'IP-Adresse': log[8] or '', + 'Zusatzinfo': log[10] or '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'audit_log_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'audit_log', + additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Audit Log') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Audit Log'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/customers") +@login_required +def export_customers(): + conn = get_connection() + cur = conn.cursor() + + # Check if test data should be included + include_test = request.args.get('include_test', 'false').lower() == 'true' + + # Build query based on test data filter + if include_test: + # Include all customers + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + else: + # Exclude test customers and test licenses + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.is_test = FALSE + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + + cur.execute(query) + + # Spaltennamen + columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', + 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') + + # Testdaten formatting + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'customer', + additional_info=f"Export aller Kunden als {export_format.upper()}") + filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Kunden', index=False) + + # Formatierung + worksheet = writer.sheets['Kunden'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/sessions") +@login_required +def export_sessions(): + conn = get_connection() + cur = conn.cursor() + + # Holen des Session-Typs (active oder ended) + session_type = request.args.get('type', 'active') + export_format = request.args.get('format', 'excel') + + # Daten je nach Typ abrufen + if session_type == 'active': + # Aktive Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = true + ORDER BY s.last_heartbeat DESC + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Aktive Sessions' + filename_prefix = 'aktive_sessions' + else: + # Beendete Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = false AND s.ended_at IS NOT NULL + ORDER BY s.ended_at DESC + LIMIT 1000 + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] if sess[6] else 0 + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Beendete Sessions' + filename_prefix = 'beendete_sessions' + + cur.close() + conn.close() + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'{filename_prefix}_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'sessions', + additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name=sheet_name) + + # Spaltenbreiten anpassen + worksheet = writer.sheets[sheet_name] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/resources") +@login_required +def export_resources(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_type = request.args.get('type', '') + filter_status = request.args.get('status', '') + search_query = request.args.get('search', '') + show_test = request.args.get('show_test', 'false').lower() == 'true' + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, + r.created_at, r.status_changed_at, + l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type + FROM resource_pools r + LEFT JOIN licenses l ON r.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE 1=1 + """ + params = [] + + # Filter für Testdaten + if not show_test: + query += " AND (r.is_test = false OR r.is_test IS NULL)" + + # Filter für Ressourcentyp + if filter_type: + query += " AND r.resource_type = %s" + params.append(filter_type) + + # Filter für Status + if filter_status: + query += " AND r.status = %s" + params.append(filter_status) + + # Suchfilter + if search_query: + query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" + params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) + + query += " ORDER BY r.id DESC" + + cur.execute(query, params) + resources = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for res in resources: + status_text = { + 'available': 'Verfügbar', + 'allocated': 'Zugewiesen', + 'quarantine': 'Quarantäne' + }.get(res[3], res[3]) + + type_text = { + 'domain': 'Domain', + 'ipv4': 'IPv4', + 'phone': 'Telefon' + }.get(res[1], res[1]) + + data.append({ + 'ID': res[0], + 'Typ': type_text, + 'Ressource': res[2], + 'Status': status_text, + 'Lizenzschlüssel': res[7] or '', + 'Kunde': res[8] or '', + 'Kunden-Email': res[9] or '', + 'Lizenztyp': res[10] or '', + 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', + 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'resources_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'resources', + additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Resources') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Resources'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/audit") +@login_required +def audit_log(): + conn = get_connection() + cur = conn.cursor() + + # Parameter + filter_user = request.args.get('user', '').strip() + filter_action = request.args.get('action', '').strip() + filter_entity = request.args.get('entity', '').strip() + page = request.args.get('page', 1, type=int) + sort = request.args.get('sort', 'timestamp') + order = request.args.get('order', 'desc') + per_page = 50 + + # Whitelist für erlaubte Sortierfelder + allowed_sort_fields = { + 'timestamp': 'timestamp', + 'username': 'username', + 'action': 'action', + 'entity': 'entity_type', + 'ip': 'ip_address' + } + + # Validierung + if sort not in allowed_sort_fields: + sort = 'timestamp' + if order not in ['asc', 'desc']: + order = 'desc' + + sort_field = allowed_sort_fields[sort] + + # SQL Query mit optionalen Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + + params = [] + + # Filter + if filter_user: + query += " AND LOWER(username) LIKE LOWER(%s)" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + # Gesamtanzahl für Pagination + count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" + cur.execute(count_query, params) + total = cur.fetchone()[0] + + # Pagination + offset = (page - 1) * per_page + query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + logs = cur.fetchall() + + # JSON-Werte parsen + parsed_logs = [] + for log in logs: + parsed_log = list(log) + # old_values und new_values sind bereits Dictionaries (JSONB) + # Keine Konvertierung nötig + parsed_logs.append(parsed_log) + + # Pagination Info + total_pages = (total + per_page - 1) // per_page + + cur.close() + conn.close() + + return render_template("audit_log.html", + logs=parsed_logs, + filter_user=filter_user, + filter_action=filter_action, + filter_entity=filter_entity, + page=page, + total_pages=total_pages, + total=total, + sort=sort, + order=order, + 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) + +@app.route("/backup/delete/", methods=["DELETE"]) +@login_required +def delete_backup(backup_id): + """Löscht ein Backup""" + conn = get_connection() + cur = conn.cursor() + + try: + # Backup-Informationen abrufen + cur.execute(""" + SELECT filename, filepath + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + if not backup_info: + return jsonify({ + 'success': False, + 'message': 'Backup nicht gefunden' + }), 404 + + filename, filepath = backup_info + filepath = Path(filepath) + + # Datei löschen, wenn sie existiert + if filepath.exists(): + filepath.unlink() + + # Aus Datenbank löschen + cur.execute(""" + DELETE FROM backup_history + WHERE id = %s + """, (backup_id,)) + + conn.commit() + + # Audit-Log + log_audit('DELETE', 'backup', backup_id, + additional_info=f"Backup gelöscht: {filename}") + + return jsonify({ + 'success': True, + 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' + }) + + except Exception as e: + conn.rollback() + return jsonify({ + 'success': False, + 'message': f'Fehler beim Löschen des Backups: {str(e)}' + }), 500 + finally: + cur.close() + conn.close() + +@app.route("/security/blocked-ips") +@login_required +def blocked_ips(): + """Zeigt alle gesperrten IPs an""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT + ip_address, + attempt_count, + first_attempt, + last_attempt, + blocked_until, + last_username_tried, + last_error_message + FROM login_attempts + WHERE blocked_until IS NOT NULL + ORDER BY blocked_until DESC + """) + + blocked_ips_list = [] + for ip in cur.fetchall(): + blocked_ips_list.append({ + 'ip_address': ip[0], + 'attempt_count': ip[1], + 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), + 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), + 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), + 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), + 'last_username': ip[5], + 'last_error': ip[6] + }) + + cur.close() + conn.close() + + return render_template("blocked_ips.html", + blocked_ips=blocked_ips_list, + username=session.get('username')) + +@app.route("/security/unblock-ip", methods=["POST"]) +@login_required +def unblock_ip(): + """Entsperrt eine IP-Adresse""" + ip_address = request.form.get('ip_address') + + if ip_address: + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + UPDATE login_attempts + SET blocked_until = NULL + WHERE ip_address = %s + """, (ip_address,)) + + conn.commit() + cur.close() + conn.close() + + # Audit-Log + log_audit('UNBLOCK_IP', 'security', + additional_info=f"IP {ip_address} manuell entsperrt") + + return redirect(url_for('blocked_ips')) + +@app.route("/security/clear-attempts", methods=["POST"]) +@login_required +def clear_attempts(): + """Löscht alle Login-Versuche für eine IP""" + ip_address = request.form.get('ip_address') + + if ip_address: + reset_login_attempts(ip_address) + + # Audit-Log + log_audit('CLEAR_ATTEMPTS', 'security', + additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") + + return redirect(url_for('blocked_ips')) + +# API Endpoints for License Management +@app.route("/api/license//toggle", methods=["POST"]) +@login_required +def toggle_license_api(license_id): + """Toggle license active status via API""" + try: + data = request.get_json() + is_active = data.get('is_active', False) + + conn = get_connection() + cur = conn.cursor() + + # Update license status + cur.execute(""" + UPDATE licenses + SET is_active = %s + WHERE id = %s + """, (is_active, license_id)) + + conn.commit() + + # Log the action + log_audit('UPDATE', 'license', license_id, + new_values={'is_active': is_active}, + additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-activate", methods=["POST"]) +@login_required +def bulk_activate_licenses(): + """Activate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = TRUE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': True, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen aktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) +@login_required +def bulk_deactivate_licenses(): + """Deactivate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = FALSE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': False, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen deaktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/license//devices") +@login_required +def get_license_devices(license_id): + """Hole alle registrierten Geräte einer Lizenz""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und hole device_limit + cur.execute(""" + SELECT device_limit FROM licenses WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit = license_data[0] + + # Hole alle Geräte für diese Lizenz + cur.execute(""" + SELECT id, hardware_id, device_name, operating_system, + first_seen, last_seen, is_active, ip_address + FROM device_registrations + WHERE license_id = %s + ORDER BY is_active DESC, last_seen DESC + """, (license_id,)) + + devices = [] + for row in cur.fetchall(): + devices.append({ + 'id': row[0], + 'hardware_id': row[1], + 'device_name': row[2] or 'Unbekanntes Gerät', + 'operating_system': row[3] or 'Unbekannt', + 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', + 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', + 'is_active': row[6], + 'ip_address': row[7] or '-' + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'devices': devices, + 'device_limit': device_limit, + 'active_count': sum(1 for d in devices if d['is_active']) + }) + + except Exception as e: + logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 + +@app.route("/api/license//register-device", methods=["POST"]) +def register_device(license_id): + """Registriere ein neues Gerät für eine Lizenz""" + try: + data = request.get_json() + hardware_id = data.get('hardware_id') + device_name = data.get('device_name', '') + operating_system = data.get('operating_system', '') + + if not hardware_id: + return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und aktiv ist + cur.execute(""" + SELECT device_limit, is_active, valid_until + FROM licenses + WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit, is_active, valid_until = license_data + + # Prüfe ob Lizenz aktiv und gültig ist + if not is_active: + return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 + + if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): + return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 + + # Prüfe ob Gerät bereits registriert ist + cur.execute(""" + SELECT id, is_active FROM device_registrations + WHERE license_id = %s AND hardware_id = %s + """, (license_id, hardware_id)) + existing_device = cur.fetchone() + + if existing_device: + device_id, is_device_active = existing_device + if is_device_active: + # Gerät ist bereits aktiv, update last_seen + cur.execute(""" + UPDATE device_registrations + SET last_seen = CURRENT_TIMESTAMP, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) + else: + # Gerät war deaktiviert, prüfe ob wir es reaktivieren können + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Reaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = TRUE, + last_seen = CURRENT_TIMESTAMP, + deactivated_at = NULL, + deactivated_by = NULL, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) + + # Neues Gerät - prüfe Gerätelimit + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Registriere neues Gerät + cur.execute(""" + INSERT INTO device_registrations + (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, (license_id, hardware_id, device_name, operating_system, + get_client_ip(), request.headers.get('User-Agent', ''))) + device_id = cur.fetchone()[0] + + conn.commit() + + # Audit Log + log_audit('DEVICE_REGISTER', 'device', device_id, + new_values={'license_id': license_id, 'hardware_id': hardware_id}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) + + except Exception as e: + logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 + +@app.route("/api/license//deactivate-device/", methods=["POST"]) +@login_required +def deactivate_device(license_id, device_id): + """Deaktiviere ein registriertes Gerät""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob das Gerät zu dieser Lizenz gehört + cur.execute(""" + SELECT id FROM device_registrations + WHERE id = %s AND license_id = %s AND is_active = TRUE + """, (device_id, license_id)) + + if not cur.fetchone(): + return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 + + # Deaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = FALSE, + deactivated_at = CURRENT_TIMESTAMP, + deactivated_by = %s + WHERE id = %s + """, (session['username'], device_id)) + + conn.commit() + + # Audit Log + log_audit('DEVICE_DEACTIVATE', 'device', device_id, + old_values={'is_active': True}, + new_values={'is_active': False}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) + + except Exception as e: + logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 + +@app.route("/api/licenses/bulk-delete", methods=["POST"]) +@login_required +def bulk_delete_licenses(): + """Delete multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Get license info for audit log (nur Live-Daten) + cur.execute(""" + SELECT license_key + FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + license_keys = [row[0] for row in cur.fetchall()] + + # Delete all selected licenses (nur Live-Daten) + cur.execute(""" + DELETE FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_DELETE', 'licenses', None, + old_values={'license_keys': license_keys, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen gelöscht") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +# ===================== RESOURCE POOL MANAGEMENT ===================== + +@app.route('/resources') +@login_required +def resources(): + """Resource Pool Hauptübersicht""" + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + # Statistiken abrufen + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = %s + GROUP BY resource_type + """, (show_test,)) + + stats = {} + for row in cur.fetchall(): + stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + } + + # Letzte Aktivitäten (gefiltert nach Test/Live) + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rp.resource_type, + rp.resource_value, + rh.details + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + WHERE rp.is_test = %s + ORDER BY rh.action_at DESC + LIMIT 10 + """, (show_test,)) + recent_activities = cur.fetchall() + + # Ressourcen-Liste mit Pagination + page = request.args.get('page', 1, type=int) + per_page = 50 + offset = (page - 1) * per_page + + resource_type = request.args.get('type', '') + status_filter = request.args.get('status', '') + search = request.args.get('search', '') + + # Sortierung + sort_by = request.args.get('sort', 'id') + sort_order = request.args.get('order', 'desc') + + # Base Query + query = """ + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + rp.allocated_to_license, + l.license_key, + c.name as customer_name, + rp.status_changed_at, + rp.quarantine_reason, + rp.quarantine_until, + c.id as customer_id + FROM resource_pools rp + LEFT JOIN licenses l ON rp.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rp.is_test = %s + """ + params = [show_test] + + if resource_type: + query += " AND rp.resource_type = %s" + params.append(resource_type) + + if status_filter: + query += " AND rp.status = %s" + params.append(status_filter) + + if search: + query += " AND rp.resource_value ILIKE %s" + params.append(f'%{search}%') + + # Count total + count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" + cur.execute(count_query, params) + total = cur.fetchone()[0] + total_pages = (total + per_page - 1) // per_page + + # Get paginated results with dynamic sorting + sort_column_map = { + 'id': 'rp.id', + 'type': 'rp.resource_type', + 'resource': 'rp.resource_value', + 'status': 'rp.status', + 'assigned': 'c.name', + 'changed': 'rp.status_changed_at' + } + + sort_column = sort_column_map.get(sort_by, 'rp.id') + sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' + + query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + resources = cur.fetchall() + + cur.close() + conn.close() + + return render_template('resources.html', + stats=stats, + resources=resources, + recent_activities=recent_activities, + page=page, + total_pages=total_pages, + total=total, + resource_type=resource_type, + status_filter=status_filter, + search=search, + show_test=show_test, + sort_by=sort_by, + sort_order=sort_order, + datetime=datetime, + timedelta=timedelta) + +@app.route('/resources/add', methods=['GET', 'POST']) +@login_required +def add_resources(): + """Ressourcen zum Pool hinzufügen""" + # Hole show_test Parameter für die Anzeige + show_test = request.args.get('show_test', 'false').lower() == 'true' + + if request.method == 'POST': + resource_type = request.form.get('resource_type') + resources_text = request.form.get('resources_text', '') + is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten + + # Parse resources (one per line) + resources = [r.strip() for r in resources_text.split('\n') if r.strip()] + + if not resources: + flash('Keine Ressourcen angegeben', 'error') + return redirect(url_for('add_resources', show_test=show_test)) + + conn = get_connection() + cur = conn.cursor() + + added = 0 + duplicates = 0 + + for resource_value in resources: + try: + cur.execute(""" + INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) + VALUES (%s, %s, %s, %s) + ON CONFLICT (resource_type, resource_value) DO NOTHING + """, (resource_type, resource_value, session['username'], is_test)) + + if cur.rowcount > 0: + added += 1 + # Get the inserted ID + cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", + (resource_type, resource_value)) + resource_id = cur.fetchone()[0] + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'created', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + else: + duplicates += 1 + + except Exception as e: + app.logger.error(f"Error adding resource {resource_value}: {e}") + + conn.commit() + cur.close() + conn.close() + + log_audit('CREATE', 'resource_pool', None, + new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, + additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") + + flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') + return redirect(url_for('resources', show_test=show_test)) + + return render_template('add_resources.html', show_test=show_test) + +@app.route('/resources/quarantine/', methods=['POST']) +@login_required +def quarantine_resource(resource_id): + """Ressource in Quarantäne setzen""" + reason = request.form.get('reason', 'review') + until_date = request.form.get('until_date') + notes = request.form.get('notes', '') + + conn = get_connection() + cur = conn.cursor() + + # Get current resource info + cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) + resource = cur.fetchone() + + if not resource: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + old_status = resource[2] + + # Update resource + cur.execute(""" + UPDATE resource_pools + SET status = 'quarantine', + quarantine_reason = %s, + quarantine_until = %s, + notes = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) + VALUES (%s, 'quarantined', %s, %s, %s) + """, (resource_id, session['username'], get_client_ip(), + Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource', resource_id, + old_values={'status': old_status}, + new_values={'status': 'quarantine', 'reason': reason}, + additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") + + flash('Ressource in Quarantäne gesetzt', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/resources/release', methods=['POST']) +@login_required +def release_resources(): + """Ressourcen aus Quarantäne freigeben""" + resource_ids = request.form.getlist('resource_ids') + + if not resource_ids: + flash('Keine Ressourcen ausgewählt', 'error') + return redirect(url_for('resources')) + + conn = get_connection() + cur = conn.cursor() + + released = 0 + for resource_id in resource_ids: + cur.execute(""" + UPDATE resource_pools + SET status = 'available', + quarantine_reason = NULL, + quarantine_until = NULL, + allocated_to_license = NULL, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s AND status = 'quarantine' + """, (session['username'], resource_id)) + + if cur.rowcount > 0: + released += 1 + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'released', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource_pool', None, + new_values={'released': released}, + additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") + + flash(f'{released} Ressourcen freigegeben', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/api/resources/allocate', methods=['POST']) +@login_required +def allocate_resources_api(): + """API für Ressourcen-Zuweisung bei Lizenzerstellung""" + data = request.json + license_id = data.get('license_id') + domain_count = data.get('domain_count', 1) + ipv4_count = data.get('ipv4_count', 1) + phone_count = data.get('phone_count', 1) + + conn = get_connection() + cur = conn.cursor() + + try: + allocated = {'domains': [], 'ipv4s': [], 'phones': []} + + # Allocate domains + if domain_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' + LIMIT %s FOR UPDATE + """, (domain_count,)) + domains = cur.fetchall() + + if len(domains) < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") + + for domain_id, domain_value in domains: + # Update resource status + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], domain_id)) + + # Create assignment + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, domain_id, session['username'])) + + # Log history + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (domain_id, license_id, session['username'], get_client_ip())) + + allocated['domains'].append(domain_value) + + # Allocate IPv4s (similar logic) + if ipv4_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' + LIMIT %s FOR UPDATE + """, (ipv4_count,)) + ipv4s = cur.fetchall() + + if len(ipv4s) < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") + + for ipv4_id, ipv4_value in ipv4s: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], ipv4_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, ipv4_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (ipv4_id, license_id, session['username'], get_client_ip())) + + allocated['ipv4s'].append(ipv4_value) + + # Allocate phones (similar logic) + if phone_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' + LIMIT %s FOR UPDATE + """, (phone_count,)) + phones = cur.fetchall() + + if len(phones) < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar") + + for phone_id, phone_value in phones: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], phone_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, phone_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (phone_id, license_id, session['username'], get_client_ip())) + + allocated['phones'].append(phone_value) + + # Update license resource counts + cur.execute(""" + UPDATE licenses + SET domain_count = %s, + ipv4_count = %s, + phone_count = %s + WHERE id = %s + """, (domain_count, ipv4_count, phone_count, license_id)) + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'allocated': allocated + }) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({ + 'success': False, + 'error': str(e) + }), 400 + +@app.route('/api/resources/check-availability', methods=['GET']) +@login_required +def check_resource_availability(): + """Prüft verfügbare Ressourcen""" + resource_type = request.args.get('type', '') + count = request.args.get('count', 10, type=int) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + conn = get_connection() + cur = conn.cursor() + + if resource_type: + # Spezifische Ressourcen für einen Typ + cur.execute(""" + SELECT id, resource_value + FROM resource_pools + WHERE status = 'available' + AND resource_type = %s + AND is_test = %s + ORDER BY resource_value + LIMIT %s + """, (resource_type, show_test, count)) + + resources = [] + for row in cur.fetchall(): + resources.append({ + 'id': row[0], + 'value': row[1] + }) + + cur.close() + conn.close() + + return jsonify({ + 'available': resources, + 'type': resource_type, + 'count': len(resources) + }) + else: + # Zusammenfassung aller Typen + cur.execute(""" + SELECT + resource_type, + COUNT(*) as available + FROM resource_pools + WHERE status = 'available' + AND is_test = %s + GROUP BY resource_type + """, (show_test,)) + + availability = {} + for row in cur.fetchall(): + availability[row[0]] = row[1] + + cur.close() + conn.close() + + return jsonify(availability) + +@app.route('/api/global-search', methods=['GET']) +@login_required +def global_search(): + """Global search API endpoint for searching customers and licenses""" + query = request.args.get('q', '').strip() + + if not query or len(query) < 2: + return jsonify({'customers': [], 'licenses': []}) + + conn = get_connection() + cur = conn.cursor() + + # Search pattern with wildcards + search_pattern = f'%{query}%' + + # Search customers + cur.execute(""" + SELECT id, name, email, company_name + FROM customers + WHERE (LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + OR LOWER(company_name) LIKE LOWER(%s)) + AND is_test = FALSE + ORDER BY name + LIMIT 5 + """, (search_pattern, search_pattern, search_pattern)) + + customers = [] + for row in cur.fetchall(): + customers.append({ + 'id': row[0], + 'name': row[1], + 'email': row[2], + 'company_name': row[3] + }) + + # Search licenses + cur.execute(""" + SELECT l.id, l.license_key, c.name as customer_name + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE LOWER(l.license_key) LIKE LOWER(%s) + AND l.is_test = FALSE + ORDER BY l.created_at DESC + LIMIT 5 + """, (search_pattern,)) + + licenses = [] + for row in cur.fetchall(): + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'customer_name': row[2] + }) + + cur.close() + conn.close() + + return jsonify({ + 'customers': customers, + 'licenses': licenses + }) + +@app.route('/resources/history/') +@login_required +def resource_history(resource_id): + """Zeigt die komplette Historie einer Ressource""" + conn = get_connection() + cur = conn.cursor() + + # Get complete resource info using named columns + cur.execute(""" + SELECT id, resource_type, resource_value, status, allocated_to_license, + status_changed_at, status_changed_by, quarantine_reason, + quarantine_until, created_at, notes + FROM resource_pools + WHERE id = %s + """, (resource_id,)) + row = cur.fetchone() + + if not row: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + # Create resource object with named attributes + resource = { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'allocated_to_license': row[4], + 'status_changed_at': row[5], + 'status_changed_by': row[6], + 'quarantine_reason': row[7], + 'quarantine_until': row[8], + 'created_at': row[9], + 'notes': row[10] + } + + # Get license info if allocated + license_info = None + if resource['allocated_to_license']: + cur.execute("SELECT license_key FROM licenses WHERE id = %s", + (resource['allocated_to_license'],)) + lic = cur.fetchone() + if lic: + license_info = {'license_key': lic[0]} + + # Get history with named columns + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rh.details, + rh.license_id, + rh.ip_address + FROM resource_history rh + WHERE rh.resource_id = %s + ORDER BY rh.action_at DESC + """, (resource_id,)) + + history = [] + for row in cur.fetchall(): + history.append({ + 'action': row[0], + 'action_by': row[1], + 'action_at': row[2], + 'details': row[3], + 'license_id': row[4], + 'ip_address': row[5] + }) + + cur.close() + conn.close() + + # Convert to object-like for template + class ResourceObj: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + + resource_obj = ResourceObj(resource) + history_objs = [ResourceObj(h) for h in history] + + return render_template('resource_history.html', + resource=resource_obj, + license_info=license_info, + history=history_objs) + +@app.route('/resources/metrics') +@login_required +def resources_metrics(): + """Dashboard für Resource Metrics und Reports""" + conn = get_connection() + cur = conn.cursor() + + # Overall stats with fallback values + cur.execute(""" + SELECT + COUNT(DISTINCT resource_id) as total_resources, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(cost), 0) as total_cost, + COALESCE(SUM(revenue), 0) as total_revenue, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + """) + row = cur.fetchone() + + # Calculate ROI + roi = 0 + if row[2] > 0: # if total_cost > 0 + roi = row[3] / row[2] # revenue / cost + + stats = { + 'total_resources': row[0] or 0, + 'avg_performance': row[1] or 0, + 'total_cost': row[2] or 0, + 'total_revenue': row[3] or 0, + 'total_issues': row[4] or 0, + 'roi': roi + } + + # Performance by type + cur.execute(""" + SELECT + rp.resource_type, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COUNT(DISTINCT rp.id) as resource_count + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY rp.resource_type + ORDER BY rp.resource_type + """) + performance_by_type = cur.fetchall() + + # Utilization data + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) as total, + ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent + FROM resource_pools + GROUP BY resource_type + """) + utilization_rows = cur.fetchall() + utilization_data = [ + { + 'type': row[0].upper(), + 'allocated': row[1], + 'total': row[2], + 'allocated_percent': row[3] + } + for row in utilization_rows + ] + + # Top performing resources + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COALESCE(SUM(rm.revenue), 0) as total_revenue, + COALESCE(SUM(rm.cost), 1) as total_cost, + CASE + WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 + ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) + END as roi + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rp.status != 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value + HAVING AVG(rm.performance_score) IS NOT NULL + ORDER BY avg_score DESC + LIMIT 10 + """) + top_rows = cur.fetchall() + top_performers = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'avg_score': row[3], + 'roi': row[6] + } + for row in top_rows + ] + + # Resources with issues + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + COALESCE(SUM(rm.issues_count), 0) as total_issues + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rm.issues_count > 0 OR rp.status = 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + HAVING SUM(rm.issues_count) > 0 + ORDER BY total_issues DESC + LIMIT 10 + """) + problem_rows = cur.fetchall() + problem_resources = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'total_issues': row[4] + } + for row in problem_rows + ] + + # Daily metrics for trend chart (last 30 days) + cur.execute(""" + SELECT + metric_date, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY metric_date + ORDER BY metric_date + """) + daily_rows = cur.fetchall() + daily_metrics = [ + { + 'date': row[0].strftime('%d.%m'), + 'performance': float(row[1]), + 'issues': int(row[2]) + } + for row in daily_rows + ] + + cur.close() + conn.close() + + return render_template('resource_metrics.html', + stats=stats, + performance_by_type=performance_by_type, + utilization_data=utilization_data, + top_performers=top_performers, + problem_resources=problem_resources, + daily_metrics=daily_metrics) + +@app.route('/resources/report', methods=['GET']) +@login_required +def resources_report(): + """Generiert Ressourcen-Reports oder zeigt Report-Formular""" + # Prüfe ob Download angefordert wurde + if request.args.get('download') == 'true': + report_type = request.args.get('type', 'usage') + format_type = request.args.get('format', 'excel') + date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) + date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) + + conn = get_connection() + cur = conn.cursor() + + if report_type == 'usage': + # Auslastungsreport + query = """ + SELECT + rp.resource_type, + rp.resource_value, + rp.status, + COUNT(DISTINCT rh.license_id) as unique_licenses, + COUNT(rh.id) as total_allocations, + MIN(rh.action_at) as first_used, + MAX(rh.action_at) as last_used + FROM resource_pools rp + LEFT JOIN resource_history rh ON rp.id = rh.resource_id + AND rh.action = 'allocated' + AND rh.action_at BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + ORDER BY rp.resource_type, total_allocations DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] + + elif report_type == 'performance': + # Performance-Report + query = """ + SELECT + rp.resource_type, + rp.resource_value, + AVG(rm.performance_score) as avg_performance, + SUM(rm.usage_count) as total_usage, + SUM(rm.revenue) as total_revenue, + SUM(rm.cost) as total_cost, + SUM(rm.revenue - rm.cost) as profit, + SUM(rm.issues_count) as total_issues + FROM resource_pools rp + JOIN resource_metrics rm ON rp.id = rm.resource_id + WHERE rm.metric_date BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value + ORDER BY profit DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] + + elif report_type == 'compliance': + # Compliance-Report + query = """ + SELECT + rh.action_at, + rh.action, + rh.action_by, + rp.resource_type, + rp.resource_value, + l.license_key, + c.name as customer_name, + rh.ip_address + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + LEFT JOIN licenses l ON rh.license_id = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rh.action_at BETWEEN %s AND %s + ORDER BY rh.action_at DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] + + else: # inventory report + # Inventar-Report + query = """ + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + GROUP BY resource_type + ORDER BY resource_type + """ + cur.execute(query) + columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] + + # Convert to DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + cur.close() + conn.close() + + # Generate file + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f"resource_report_{report_type}_{timestamp}" + + if format_type == 'excel': + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Report', index=False) + + # Auto-adjust columns width + worksheet = writer.sheets['Report'] + for column in worksheet.columns: + max_length = 0 + column = [cell for cell in column] + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = (max_length + 2) + worksheet.column_dimensions[column[0].column_letter].width = adjusted_width + + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx') + + else: # CSV + output = io.StringIO() + df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv') + + # Wenn kein Download, zeige Report-Formular + return render_template('resource_report.html', + datetime=datetime, + timedelta=timedelta, + username=session.get('username')) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/v2_adminpanel/comment_routes.py b/v2_adminpanel/comment_routes.py deleted file mode 100644 index 1c5c44d..0000000 --- a/v2_adminpanel/comment_routes.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to comment out routes that have been moved to blueprints -""" - -# Routes that have been moved to auth_routes.py -auth_routes = [ - ("@app.route(\"/login\"", "def login():", 138, 251), # login route - ("@app.route(\"/logout\")", "def logout():", 252, 263), # logout route - ("@app.route(\"/verify-2fa\"", "def verify_2fa():", 264, 342), # verify-2fa route - ("@app.route(\"/profile\")", "def profile():", 343, 352), # profile route - ("@app.route(\"/profile/change-password\"", "def change_password():", 353, 390), # change-password route - ("@app.route(\"/profile/setup-2fa\")", "def setup_2fa():", 391, 410), # setup-2fa route - ("@app.route(\"/profile/enable-2fa\"", "def enable_2fa():", 411, 448), # enable-2fa route - ("@app.route(\"/profile/disable-2fa\"", "def disable_2fa():", 449, 475), # disable-2fa route - ("@app.route(\"/heartbeat\"", "def heartbeat():", 476, 489), # heartbeat route -] - -# Routes that have been moved to admin_routes.py -admin_routes = [ - ("@app.route(\"/\")", "def dashboard():", 647, 870), # dashboard route - ("@app.route(\"/audit\")", "def audit_log():", 2772, 2866), # audit route - ("@app.route(\"/backups\")", "def backups():", 2866, 2901), # backups route - ("@app.route(\"/backup/create\"", "def create_backup_route():", 2901, 2919), # backup/create route - ("@app.route(\"/backup/restore/\"", "def restore_backup_route(backup_id):", 2919, 2938), # backup/restore route - ("@app.route(\"/backup/download/\")", "def download_backup(backup_id):", 2938, 2970), # backup/download route - ("@app.route(\"/backup/delete/\"", "def delete_backup(backup_id):", 2970, 3026), # backup/delete route - ("@app.route(\"/security/blocked-ips\")", "def blocked_ips():", 3026, 3067), # security/blocked-ips route - ("@app.route(\"/security/unblock-ip\"", "def unblock_ip():", 3067, 3093), # security/unblock-ip route - ("@app.route(\"/security/clear-attempts\"", "def clear_attempts():", 3093, 3119), # security/clear-attempts route -] - -print("This script would comment out the following routes:") -print("\nAuth routes:") -for route in auth_routes: - print(f" - {route[0]} (lines {route[2]}-{route[3]})") - -print("\nAdmin routes:") -for route in admin_routes: - print(f" - {route[0]} (lines {route[2]}-{route[3]})") - -print("\nNote: Manual verification and adjustment of line numbers is recommended before running the actual commenting.") \ No newline at end of file diff --git a/v2_adminpanel/init.sql b/v2_adminpanel/init.sql index fc91a66..5754e24 100644 --- a/v2_adminpanel/init.sql +++ b/v2_adminpanel/init.sql @@ -1,282 +1,282 @@ --- UTF-8 Encoding für deutsche Sonderzeichen sicherstellen -SET client_encoding = 'UTF8'; - --- Zeitzone auf Europe/Berlin setzen -SET timezone = 'Europe/Berlin'; - -CREATE TABLE IF NOT EXISTS customers ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - email TEXT, - is_test BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT unique_email UNIQUE (email) -); - -CREATE TABLE IF NOT EXISTS licenses ( - id SERIAL PRIMARY KEY, - license_key TEXT UNIQUE NOT NULL, - customer_id INTEGER REFERENCES customers(id), - license_type TEXT NOT NULL, - valid_from DATE NOT NULL, - valid_until DATE NOT NULL, - is_active BOOLEAN DEFAULT TRUE, - is_test BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS sessions ( - id SERIAL PRIMARY KEY, - license_id INTEGER REFERENCES licenses(id), - session_id TEXT UNIQUE NOT NULL, - ip_address TEXT, - user_agent TEXT, - started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - last_heartbeat TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - ended_at TIMESTAMP WITH TIME ZONE, - is_active BOOLEAN DEFAULT TRUE -); - --- Audit-Log-Tabelle für Änderungsprotokolle -CREATE TABLE IF NOT EXISTS audit_log ( - id SERIAL PRIMARY KEY, - timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - username TEXT NOT NULL, - action TEXT NOT NULL, - entity_type TEXT NOT NULL, - entity_id INTEGER, - old_values JSONB, - new_values JSONB, - ip_address TEXT, - user_agent TEXT, - additional_info TEXT -); - --- Index für bessere Performance bei Abfragen -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 WITH TIME ZONE 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); - --- Login-Attempts-Tabelle für Rate-Limiting -CREATE TABLE IF NOT EXISTS login_attempts ( - ip_address VARCHAR(45) PRIMARY KEY, - attempt_count INTEGER DEFAULT 0, - first_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - last_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - blocked_until TIMESTAMP WITH TIME ZONE NULL, - last_username_tried TEXT, - last_error_message TEXT -); - --- Index für schnelle Abfragen -CREATE INDEX idx_login_attempts_blocked_until ON login_attempts(blocked_until); -CREATE INDEX idx_login_attempts_last_attempt ON login_attempts(last_attempt DESC); - --- Migration: Füge created_at zu licenses hinzu, falls noch nicht vorhanden -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'licenses' AND column_name = 'created_at') THEN - ALTER TABLE licenses ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP; - - -- Setze created_at für bestehende Einträge auf das valid_from Datum - UPDATE licenses SET created_at = valid_from WHERE created_at IS NULL; - END IF; -END $$; - --- ===================== RESOURCE POOL SYSTEM ===================== - --- Haupttabelle für den Resource Pool -CREATE TABLE IF NOT EXISTS resource_pools ( - id SERIAL PRIMARY KEY, - resource_type VARCHAR(20) NOT NULL CHECK (resource_type IN ('domain', 'ipv4', 'phone')), - resource_value VARCHAR(255) NOT NULL, - status VARCHAR(20) DEFAULT 'available' CHECK (status IN ('available', 'allocated', 'quarantine')), - allocated_to_license INTEGER REFERENCES licenses(id) ON DELETE SET NULL, - status_changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - status_changed_by VARCHAR(50), - quarantine_reason VARCHAR(100) CHECK (quarantine_reason IN ('abuse', 'defect', 'maintenance', 'blacklisted', 'expired', 'review', NULL)), - quarantine_until TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - notes TEXT, - is_test BOOLEAN DEFAULT FALSE, - UNIQUE(resource_type, resource_value) -); - --- Resource History für vollständige Nachverfolgbarkeit -CREATE TABLE IF NOT EXISTS resource_history ( - id SERIAL PRIMARY KEY, - resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE, - license_id INTEGER REFERENCES licenses(id) ON DELETE SET NULL, - action VARCHAR(50) NOT NULL CHECK (action IN ('allocated', 'deallocated', 'quarantined', 'released', 'created', 'deleted')), - action_by VARCHAR(50) NOT NULL, - action_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - details JSONB, - ip_address TEXT -); - --- Resource Metrics für Performance-Tracking und ROI -CREATE TABLE IF NOT EXISTS resource_metrics ( - id SERIAL PRIMARY KEY, - resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE, - metric_date DATE NOT NULL, - usage_count INTEGER DEFAULT 0, - performance_score DECIMAL(5,2) DEFAULT 0.00, - cost DECIMAL(10,2) DEFAULT 0.00, - revenue DECIMAL(10,2) DEFAULT 0.00, - issues_count INTEGER DEFAULT 0, - availability_percent DECIMAL(5,2) DEFAULT 100.00, - UNIQUE(resource_id, metric_date) -); - --- Zuordnungstabelle zwischen Lizenzen und Ressourcen -CREATE TABLE IF NOT EXISTS license_resources ( - id SERIAL PRIMARY KEY, - license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, - resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE, - assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - assigned_by VARCHAR(50), - is_active BOOLEAN DEFAULT TRUE, - UNIQUE(license_id, resource_id) -); - --- Erweiterung der licenses Tabelle um Resource-Counts -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'licenses' AND column_name = 'domain_count') THEN - ALTER TABLE licenses - ADD COLUMN domain_count INTEGER DEFAULT 1 CHECK (domain_count >= 0 AND domain_count <= 10), - ADD COLUMN ipv4_count INTEGER DEFAULT 1 CHECK (ipv4_count >= 0 AND ipv4_count <= 10), - ADD COLUMN phone_count INTEGER DEFAULT 1 CHECK (phone_count >= 0 AND phone_count <= 10); - END IF; -END $$; - --- Erweiterung der licenses Tabelle um device_limit -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'licenses' AND column_name = 'device_limit') THEN - ALTER TABLE licenses - ADD COLUMN device_limit INTEGER DEFAULT 3 CHECK (device_limit >= 1 AND device_limit <= 10); - END IF; -END $$; - --- Tabelle für Geräte-Registrierungen -CREATE TABLE IF NOT EXISTS device_registrations ( - id SERIAL PRIMARY KEY, - license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, - hardware_id TEXT NOT NULL, - device_name TEXT, - operating_system TEXT, - first_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - is_active BOOLEAN DEFAULT TRUE, - deactivated_at TIMESTAMP WITH TIME ZONE, - deactivated_by TEXT, - ip_address TEXT, - user_agent TEXT, - UNIQUE(license_id, hardware_id) -); - --- Indizes für device_registrations -CREATE INDEX IF NOT EXISTS idx_device_license ON device_registrations(license_id); -CREATE INDEX IF NOT EXISTS idx_device_hardware ON device_registrations(hardware_id); -CREATE INDEX IF NOT EXISTS idx_device_active ON device_registrations(license_id, is_active) WHERE is_active = TRUE; - --- Indizes für Performance -CREATE INDEX IF NOT EXISTS idx_resource_status ON resource_pools(status); -CREATE INDEX IF NOT EXISTS idx_resource_type_status ON resource_pools(resource_type, status); -CREATE INDEX IF NOT EXISTS idx_resource_allocated ON resource_pools(allocated_to_license) WHERE allocated_to_license IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_resource_quarantine ON resource_pools(quarantine_until) WHERE status = 'quarantine'; -CREATE INDEX IF NOT EXISTS idx_resource_history_date ON resource_history(action_at DESC); -CREATE INDEX IF NOT EXISTS idx_resource_history_resource ON resource_history(resource_id); -CREATE INDEX IF NOT EXISTS idx_resource_metrics_date ON resource_metrics(metric_date DESC); -CREATE INDEX IF NOT EXISTS idx_license_resources_active ON license_resources(license_id) WHERE is_active = TRUE; - --- Users table for authentication with password and 2FA support -CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - username VARCHAR(50) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL, - email VARCHAR(100), - totp_secret VARCHAR(32), - totp_enabled BOOLEAN DEFAULT FALSE, - backup_codes TEXT, -- JSON array of hashed backup codes - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - password_reset_token VARCHAR(64), - password_reset_expires TIMESTAMP WITH TIME ZONE, - failed_2fa_attempts INTEGER DEFAULT 0, - last_failed_2fa TIMESTAMP WITH TIME ZONE -); - --- Index for faster login lookups -CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); -CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL; - --- Migration: Add is_test column to licenses if it doesn't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'licenses' AND column_name = 'is_test') THEN - ALTER TABLE licenses ADD COLUMN is_test BOOLEAN DEFAULT FALSE; - - -- Mark all existing licenses as test data - UPDATE licenses SET is_test = TRUE; - - -- Add index for better performance when filtering test data - CREATE INDEX idx_licenses_is_test ON licenses(is_test); - END IF; -END $$; - --- Migration: Add is_test column to customers if it doesn't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'customers' AND column_name = 'is_test') THEN - ALTER TABLE customers ADD COLUMN is_test BOOLEAN DEFAULT FALSE; - - -- Mark all existing customers as test data - UPDATE customers SET is_test = TRUE; - - -- Add index for better performance - CREATE INDEX idx_customers_is_test ON customers(is_test); - END IF; -END $$; - --- Migration: Add is_test column to resource_pools if it doesn't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'resource_pools' AND column_name = 'is_test') THEN - ALTER TABLE resource_pools ADD COLUMN is_test BOOLEAN DEFAULT FALSE; - - -- Mark all existing resources as test data - UPDATE resource_pools SET is_test = TRUE; - - -- Add index for better performance - CREATE INDEX idx_resource_pools_is_test ON resource_pools(is_test); - END IF; -END $$; +-- UTF-8 Encoding für deutsche Sonderzeichen sicherstellen +SET client_encoding = 'UTF8'; + +-- Zeitzone auf Europe/Berlin setzen +SET timezone = 'Europe/Berlin'; + +CREATE TABLE IF NOT EXISTS customers ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT, + is_test BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_email UNIQUE (email) +); + +CREATE TABLE IF NOT EXISTS licenses ( + id SERIAL PRIMARY KEY, + license_key TEXT UNIQUE NOT NULL, + customer_id INTEGER REFERENCES customers(id), + license_type TEXT NOT NULL, + valid_from DATE NOT NULL, + valid_until DATE NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + is_test BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS sessions ( + id SERIAL PRIMARY KEY, + license_id INTEGER REFERENCES licenses(id), + session_id TEXT UNIQUE NOT NULL, + ip_address TEXT, + user_agent TEXT, + started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_heartbeat TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + ended_at TIMESTAMP WITH TIME ZONE, + is_active BOOLEAN DEFAULT TRUE +); + +-- Audit-Log-Tabelle für Änderungsprotokolle +CREATE TABLE IF NOT EXISTS audit_log ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + username TEXT NOT NULL, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER, + old_values JSONB, + new_values JSONB, + ip_address TEXT, + user_agent TEXT, + additional_info TEXT +); + +-- Index für bessere Performance bei Abfragen +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 WITH TIME ZONE 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); + +-- Login-Attempts-Tabelle für Rate-Limiting +CREATE TABLE IF NOT EXISTS login_attempts ( + ip_address VARCHAR(45) PRIMARY KEY, + attempt_count INTEGER DEFAULT 0, + first_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + blocked_until TIMESTAMP WITH TIME ZONE NULL, + last_username_tried TEXT, + last_error_message TEXT +); + +-- Index für schnelle Abfragen +CREATE INDEX idx_login_attempts_blocked_until ON login_attempts(blocked_until); +CREATE INDEX idx_login_attempts_last_attempt ON login_attempts(last_attempt DESC); + +-- Migration: Füge created_at zu licenses hinzu, falls noch nicht vorhanden +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'licenses' AND column_name = 'created_at') THEN + ALTER TABLE licenses ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP; + + -- Setze created_at für bestehende Einträge auf das valid_from Datum + UPDATE licenses SET created_at = valid_from WHERE created_at IS NULL; + END IF; +END $$; + +-- ===================== RESOURCE POOL SYSTEM ===================== + +-- Haupttabelle für den Resource Pool +CREATE TABLE IF NOT EXISTS resource_pools ( + id SERIAL PRIMARY KEY, + resource_type VARCHAR(20) NOT NULL CHECK (resource_type IN ('domain', 'ipv4', 'phone')), + resource_value VARCHAR(255) NOT NULL, + status VARCHAR(20) DEFAULT 'available' CHECK (status IN ('available', 'allocated', 'quarantine')), + allocated_to_license INTEGER REFERENCES licenses(id) ON DELETE SET NULL, + status_changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + status_changed_by VARCHAR(50), + quarantine_reason VARCHAR(100) CHECK (quarantine_reason IN ('abuse', 'defect', 'maintenance', 'blacklisted', 'expired', 'review', NULL)), + quarantine_until TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + notes TEXT, + is_test BOOLEAN DEFAULT FALSE, + UNIQUE(resource_type, resource_value) +); + +-- Resource History für vollständige Nachverfolgbarkeit +CREATE TABLE IF NOT EXISTS resource_history ( + id SERIAL PRIMARY KEY, + resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE, + license_id INTEGER REFERENCES licenses(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL CHECK (action IN ('allocated', 'deallocated', 'quarantined', 'released', 'created', 'deleted')), + action_by VARCHAR(50) NOT NULL, + action_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + details JSONB, + ip_address TEXT +); + +-- Resource Metrics für Performance-Tracking und ROI +CREATE TABLE IF NOT EXISTS resource_metrics ( + id SERIAL PRIMARY KEY, + resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE, + metric_date DATE NOT NULL, + usage_count INTEGER DEFAULT 0, + performance_score DECIMAL(5,2) DEFAULT 0.00, + cost DECIMAL(10,2) DEFAULT 0.00, + revenue DECIMAL(10,2) DEFAULT 0.00, + issues_count INTEGER DEFAULT 0, + availability_percent DECIMAL(5,2) DEFAULT 100.00, + UNIQUE(resource_id, metric_date) +); + +-- Zuordnungstabelle zwischen Lizenzen und Ressourcen +CREATE TABLE IF NOT EXISTS license_resources ( + id SERIAL PRIMARY KEY, + license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, + resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE, + assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + assigned_by VARCHAR(50), + is_active BOOLEAN DEFAULT TRUE, + UNIQUE(license_id, resource_id) +); + +-- Erweiterung der licenses Tabelle um Resource-Counts +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'licenses' AND column_name = 'domain_count') THEN + ALTER TABLE licenses + ADD COLUMN domain_count INTEGER DEFAULT 1 CHECK (domain_count >= 0 AND domain_count <= 10), + ADD COLUMN ipv4_count INTEGER DEFAULT 1 CHECK (ipv4_count >= 0 AND ipv4_count <= 10), + ADD COLUMN phone_count INTEGER DEFAULT 1 CHECK (phone_count >= 0 AND phone_count <= 10); + END IF; +END $$; + +-- Erweiterung der licenses Tabelle um device_limit +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'licenses' AND column_name = 'device_limit') THEN + ALTER TABLE licenses + ADD COLUMN device_limit INTEGER DEFAULT 3 CHECK (device_limit >= 1 AND device_limit <= 10); + END IF; +END $$; + +-- Tabelle für Geräte-Registrierungen +CREATE TABLE IF NOT EXISTS device_registrations ( + id SERIAL PRIMARY KEY, + license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, + hardware_id TEXT NOT NULL, + device_name TEXT, + operating_system TEXT, + first_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE, + deactivated_at TIMESTAMP WITH TIME ZONE, + deactivated_by TEXT, + ip_address TEXT, + user_agent TEXT, + UNIQUE(license_id, hardware_id) +); + +-- Indizes für device_registrations +CREATE INDEX IF NOT EXISTS idx_device_license ON device_registrations(license_id); +CREATE INDEX IF NOT EXISTS idx_device_hardware ON device_registrations(hardware_id); +CREATE INDEX IF NOT EXISTS idx_device_active ON device_registrations(license_id, is_active) WHERE is_active = TRUE; + +-- Indizes für Performance +CREATE INDEX IF NOT EXISTS idx_resource_status ON resource_pools(status); +CREATE INDEX IF NOT EXISTS idx_resource_type_status ON resource_pools(resource_type, status); +CREATE INDEX IF NOT EXISTS idx_resource_allocated ON resource_pools(allocated_to_license) WHERE allocated_to_license IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_resource_quarantine ON resource_pools(quarantine_until) WHERE status = 'quarantine'; +CREATE INDEX IF NOT EXISTS idx_resource_history_date ON resource_history(action_at DESC); +CREATE INDEX IF NOT EXISTS idx_resource_history_resource ON resource_history(resource_id); +CREATE INDEX IF NOT EXISTS idx_resource_metrics_date ON resource_metrics(metric_date DESC); +CREATE INDEX IF NOT EXISTS idx_license_resources_active ON license_resources(license_id) WHERE is_active = TRUE; + +-- Users table for authentication with password and 2FA support +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + email VARCHAR(100), + totp_secret VARCHAR(32), + totp_enabled BOOLEAN DEFAULT FALSE, + backup_codes TEXT, -- JSON array of hashed backup codes + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + password_reset_token VARCHAR(64), + password_reset_expires TIMESTAMP WITH TIME ZONE, + failed_2fa_attempts INTEGER DEFAULT 0, + last_failed_2fa TIMESTAMP WITH TIME ZONE +); + +-- Index for faster login lookups +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL; + +-- Migration: Add is_test column to licenses if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'licenses' AND column_name = 'is_test') THEN + ALTER TABLE licenses ADD COLUMN is_test BOOLEAN DEFAULT FALSE; + + -- Mark all existing licenses as test data + UPDATE licenses SET is_test = TRUE; + + -- Add index for better performance when filtering test data + CREATE INDEX idx_licenses_is_test ON licenses(is_test); + END IF; +END $$; + +-- Migration: Add is_test column to customers if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'customers' AND column_name = 'is_test') THEN + ALTER TABLE customers ADD COLUMN is_test BOOLEAN DEFAULT FALSE; + + -- Mark all existing customers as test data + UPDATE customers SET is_test = TRUE; + + -- Add index for better performance + CREATE INDEX idx_customers_is_test ON customers(is_test); + END IF; +END $$; + +-- Migration: Add is_test column to resource_pools if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'resource_pools' AND column_name = 'is_test') THEN + ALTER TABLE resource_pools ADD COLUMN is_test BOOLEAN DEFAULT FALSE; + + -- Mark all existing resources as test data + UPDATE resource_pools SET is_test = TRUE; + + -- Add index for better performance + CREATE INDEX idx_resource_pools_is_test ON resource_pools(is_test); + END IF; +END $$; diff --git a/v2_adminpanel/requirements.txt b/v2_adminpanel/requirements.txt index f1fcc95..8588045 100644 --- a/v2_adminpanel/requirements.txt +++ b/v2_adminpanel/requirements.txt @@ -1,14 +1,14 @@ -flask -flask-session -psycopg2-binary -python-dotenv -pyopenssl -pandas -openpyxl -cryptography -apscheduler -requests -python-dateutil -bcrypt -pyotp -qrcode[pil] +flask +flask-session +psycopg2-binary +python-dotenv +pyopenssl +pandas +openpyxl +cryptography +apscheduler +requests +python-dateutil +bcrypt +pyotp +qrcode[pil] diff --git a/v2_adminpanel/templates/create_customer.html b/v2_adminpanel/templates/create_customer.html index 55518fc..689ad85 100644 --- a/v2_adminpanel/templates/create_customer.html +++ b/v2_adminpanel/templates/create_customer.html @@ -1,71 +1,71 @@ -{% extends "base.html" %} - -{% block title %}Neuer Kunde{% endblock %} - -{% block content %} -
-
-

👤 Neuer Kunde anlegen

- ← Zurück zur Übersicht -
- -
-
-
-
-
- - -
Der Name des Kunden oder der Firma
-
-
- - -
Kontakt-E-Mail-Adresse des Kunden
-
-
- -
- - -
- - - -
- - Abbrechen -
-
-
-
-
- - -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} - - {% endfor %} -
- {% endif %} -{% endwith %} +{% extends "base.html" %} + +{% block title %}Neuer Kunde{% endblock %} + +{% block content %} +
+
+

👤 Neuer Kunde anlegen

+ ← Zurück zur Übersicht +
+ +
+
+
+
+
+ + +
Der Name des Kunden oder der Firma
+
+
+ + +
Kontakt-E-Mail-Adresse des Kunden
+
+
+ +
+ + +
+ + + +
+ + Abbrechen +
+
+
+
+
+ + +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} +{% endwith %} {% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/index.html b/v2_adminpanel/templates/index.html index a14329a..2e0810f 100644 --- a/v2_adminpanel/templates/index.html +++ b/v2_adminpanel/templates/index.html @@ -1,533 +1,533 @@ -{% extends "base.html" %} - -{% block title %}Admin Panel{% endblock %} - -{% block content %} -
-
-

Neue Lizenz erstellen

- ← Zurück zur Übersicht -
- -
-
-
- - -
- - -
- -
- - -
-
Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ (F=Full, T=Test)
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- Ressourcen-Zuweisung - -
-
-
-
-
- - - - Verfügbar: - - -
-
- - - - Verfügbar: - - -
-
- - - - Verfügbar: - - -
-
- -
-
- - -
-
-
- Gerätelimit -
-
-
-
-
- - - - Anzahl der Geräte, auf denen die Lizenz gleichzeitig aktiviert sein kann. - -
-
-
-
- - -
- - -
- -
- -
-
-
- - -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} - - {% endfor %} -
- {% endif %} -{% endwith %} - - -{% endblock %} +{% extends "base.html" %} + +{% block title %}Admin Panel{% endblock %} + +{% block content %} +
+
+

Neue Lizenz erstellen

+ ← Zurück zur Übersicht +
+ +
+
+
+ + +
+ + +
+ +
+ + +
+
Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ (F=Full, T=Test)
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Ressourcen-Zuweisung + +
+
+
+
+
+ + + + Verfügbar: - + +
+
+ + + + Verfügbar: - + +
+
+ + + + Verfügbar: - + +
+
+ +
+
+ + +
+
+
+ Gerätelimit +
+
+
+
+
+ + + + Anzahl der Geräte, auf denen die Lizenz gleichzeitig aktiviert sein kann. + +
+
+
+
+ + +
+ + +
+ +
+ +
+
+
+ + +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} +{% endwith %} + + +{% endblock %} diff --git a/v2_nginx/nginx.conf b/v2_nginx/nginx.conf index 5830a2f..c3bbf80 100644 --- a/v2_nginx/nginx.conf +++ b/v2_nginx/nginx.conf @@ -1,99 +1,99 @@ -events { - worker_connections 1024; -} - -http { - # Moderne SSL-Einstellungen für maximale Sicherheit - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384'; - ssl_prefer_server_ciphers off; - - # SSL Session Einstellungen - ssl_session_timeout 1d; - ssl_session_cache shared:SSL:10m; - ssl_session_tickets off; - - # OCSP Stapling - ssl_stapling on; - ssl_stapling_verify on; - resolver 8.8.8.8 8.8.4.4 valid=300s; - resolver_timeout 5s; - - # DH parameters für Perfect Forward Secrecy - ssl_dhparam /etc/nginx/ssl/dhparam.pem; - - # Admin Panel - server { - listen 80; - server_name admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com; - - # Redirect HTTP to HTTPS - return 301 https://$server_name$request_uri; - } - - server { - listen 443 ssl; - server_name admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com; - - # SSL-Zertifikate (echte Zertifikate) - ssl_certificate /etc/nginx/ssl/fullchain.pem; - ssl_certificate_key /etc/nginx/ssl/privkey.pem; - - # Security Headers - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - - # Proxy-Einstellungen - location / { - proxy_pass http://admin-panel:5000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # WebSocket support (falls benötigt) - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } - } - - # API Server (für später) - server { - listen 80; - server_name api-software-undso.z5m7q9dk3ah2v1plx6ju.com; - - return 301 https://$server_name$request_uri; - } - - server { - listen 443 ssl; - server_name api-software-undso.z5m7q9dk3ah2v1plx6ju.com; - - ssl_certificate /etc/nginx/ssl/fullchain.pem; - ssl_certificate_key /etc/nginx/ssl/privkey.pem; - - # Security Headers - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - - location / { - proxy_pass http://license-server:8443; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # WebSocket support (falls benötigt) - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } - } +events { + worker_connections 1024; +} + +http { + # Moderne SSL-Einstellungen für maximale Sicherheit + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384'; + ssl_prefer_server_ciphers off; + + # SSL Session Einstellungen + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + # OCSP Stapling + ssl_stapling on; + ssl_stapling_verify on; + resolver 8.8.8.8 8.8.4.4 valid=300s; + resolver_timeout 5s; + + # DH parameters für Perfect Forward Secrecy + ssl_dhparam /etc/nginx/ssl/dhparam.pem; + + # Admin Panel + server { + listen 80; + server_name admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com; + + # Redirect HTTP to HTTPS + return 301 https://$server_name$request_uri; + } + + server { + listen 443 ssl; + server_name admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com; + + # SSL-Zertifikate (echte Zertifikate) + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + + # Security Headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Proxy-Einstellungen + location / { + proxy_pass http://admin-panel:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support (falls benötigt) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + } + + # API Server (für später) + server { + listen 80; + server_name api-software-undso.z5m7q9dk3ah2v1plx6ju.com; + + return 301 https://$server_name$request_uri; + } + + server { + listen 443 ssl; + server_name api-software-undso.z5m7q9dk3ah2v1plx6ju.com; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + + # Security Headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + location / { + proxy_pass http://license-server:8443; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support (falls benötigt) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + } } \ No newline at end of file