diff --git a/.gitignore b/.gitignore index acc054a..c33a814 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ __pycache__/ /srv/poe_manager/fernet.key /srv/poe_manager/secret.key /srv/poe_manager/known_hosts + +# Laufzeit-Uploads (Profilbilder) — werden bei Bedarf automatisch angelegt +/srv/poe_manager/static/uploads/ diff --git a/README.md b/README.md index f657ff3..7d1c640 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,23 @@ Die App ermöglicht: (siehe [Rechtesystem](#rechtesystem-gruppen--berechtigungen)) - Einklappbare Sidebar (Desktop: dauerhaft, über Neuladen hinweg gemerkt; mobil: temporäres Überlagern) +- **Änderungslog**: jede Anlage/Bearbeitung/Löschung sowie Aktivieren/ + Deaktivieren von Geräten, Switchen, Zugangsdaten, Benutzern und Gruppen + wird protokolliert (wer, wann, was) — einsehbar unter „Logs → Änderungslog“; + Geräte und Switche merken sich zusätzlich direkt am Datensatz, wer sie + zuletzt geändert hat. PoE-Neustarts werden bewusst **nicht** geloggt + (siehe [Änderungslog](#änderungslog)) +- **Verschlüsselter Import/Export**: Geräte, Switche und Zugangsdaten lassen + sich unter Einstellungen als ein mit selbstgewählter Passphrase + verschlüsseltes Bundle exportieren und auf einer neuen Umgebung wieder + importieren (siehe [Import/Export](#importexport)) +- **Eigenes Profil**: jeder Benutzer kann über einen Klick auf seinen Namen + in der Sidebar Vor-/Nachname, eigenes Passwort und ein Profilbild pflegen + (siehe [Eigenes Profil](#eigenes-profil--profilbild)) +- **Anpassbare Navigation**: Admins können die Reihenfolge der Sidebar- + Menüpunkte unter Einstellungen per Auf/Ab-Buttons festlegen; jeder Benutzer + sieht davon ohnehin nur, wofür er berechtigt ist (siehe + [Navigation anpassen](#navigation-anpassen)) ## Was ist neu gegenüber dem Original? @@ -136,6 +153,57 @@ Löschen ist nur möglich, wenn kein Switch mehr auf den Eintrag verweist. Bestehende Datenbanken aus einer älteren Version (Username/Passwort direkt am Switch) werden beim ersten Start automatisch migriert. +## Änderungslog + +Jede Anlage, Bearbeitung, Löschung sowie jedes Aktivieren/Deaktivieren von +Geräten, Switchen, Zugangsdaten, Benutzern und Gruppen wird in der Tabelle +`audit_log` protokolliert (Zeitpunkt, Benutzer, Aktion, Ziel, Details) — +einsehbar unter **Logs → Änderungslog** (nur für Admins). Zusätzlich merken +sich Geräte und Switche direkt am Datensatz (`last_modified_by`, +`last_modified_at`), wer sie zuletzt geändert hat, damit man das nicht erst +im Log nachschlagen muss. + +PoE-Neustarts werden **bewusst nicht** im Änderungslog erfasst — die stehen +bereits im Live-Log von `poe.sh`. Eine Ausnahme ist die Änderung des +Prüfintervalls selbst (eine Einstellungsänderung, kein Neustart), die wird +mitprotokolliert. + +## Import/Export + +Unter **Einstellungen** lassen sich Geräte, Switche und Zugangsdaten +(inkl. entschlüsselter Passwörter) als ein einziges JSON-Bundle exportieren. +Das Bundle wird mit einem aus einer selbstgewählten Passphrase abgeleiteten +Schlüssel verschlüsselt (PBKDF2-HMAC-SHA256 + Fernet) — unabhängig vom +`fernet.key` dieser Installation, damit die Datei auf einer komplett neuen +Umgebung (mit eigenem, neu generiertem `fernet.key`) wieder eingelesen +werden kann. Gedacht für einen Umzug auf einen neuen Server. + +Beim Import wird dieselbe Passphrase erneut abgefragt; Zugangsdaten werden +nach Name, Switche nach Hostname und Geräte nach MAC-Adresse abgeglichen — +bestehende Einträge mit gleichem Schlüssel werden aktualisiert, neue werden +angelegt. Switche referenzieren ihre Zugangsdaten dabei über den Namen (nicht +die interne ID), damit der Import auch bei abweichender ID-Vergabe auf dem +Zielsystem korrekt verknüpft. + +## Eigenes Profil & Profilbild + +Ein Klick auf den eigenen Namen unten in der Sidebar öffnet „Mein Profil“: +Vor-/Nachname ändern, ein Profilbild hochladen (PNG/JPG/GIF/WEBP) und das +eigene Passwort ändern (erfordert Eingabe des aktuellen Passworts). Das ist +bewusst getrennt vom Admin-Bereich „Benutzer“, der andere Konten verwaltet. +Ist ein Profilbild hinterlegt, erscheint es statt der Initialen sowohl in +der Sidebar als auch im Änderungslog neben dem Benutzernamen. + +## Navigation anpassen + +Die Reihenfolge der Sidebar-Menüpunkte ist unter **Einstellungen** per +Auf-/Ab-Buttons konfigurierbar (gespeichert als JSON-Liste unter +`settings.nav_order`) und gilt global für alle Benutzer. Unabhängig von der +Reihenfolge sieht jeder Benutzer ohnehin nur die Punkte, für die er auch +berechtigt ist — ein Benutzer ohne jede Gruppe sieht z.B. nur „Dashboard“. +„Logs“ ist dabei eine aufklappbare Gruppe mit den Unterpunkten „Live-Log“ +und „Änderungslog“. + ## Entwicklung unter WSL Da das Projekt ausschließlich für Linux gebaut ist, empfiehlt sich die diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index 5c226b0..cd8e5a7 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -8,13 +8,16 @@ Switch-/User-Verwaltung, Live-Log, Settings, manueller PoE-Neustart), lediglich mit modernisiertem Frontend und aufgeräumten/konfigurierbaren Pfaden im Backend. """ -from flask import Flask, render_template, request, redirect, url_for, flash, jsonify +from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_from_directory from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user from flask_bcrypt import Bcrypt from flask_sock import Sock +from werkzeug.utils import secure_filename from cryptography.fernet import Fernet +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives import hashes from datetime import datetime -import sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback +import base64, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback import paramiko @@ -73,6 +76,9 @@ LOG_GLOB = os.environ.get("POE_LOG_GLOB", "/var/log/rpi-*.log") FERNET_KEY_PATH = os.environ.get("POE_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key")) SSH_KNOWN_HOSTS_PATH = os.environ.get("POE_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts")) SECRET_KEY_PATH = os.environ.get("POE_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key")) +AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars") +ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"} +os.makedirs(AVATAR_DIR, exist_ok=True) app = Flask(__name__) @@ -143,14 +149,66 @@ ALL_PERMISSION_KEYS = [key for cat in PERMISSIONS.values() for key in cat["items DEFAULT_GROUP_NAME = "Benutzer" DEFAULT_GROUP_PERMISSIONS = ["devices.view", "switches.view"] +# --------------------------------------------------------------------------- +# Navbar — Reihenfolge ist admin-konfigurierbar (siehe settings(), gespeichert +# als JSON-Liste von Keys unter settings.nav_order); standardmäßig wird pro +# Benutzer nur angezeigt, wofür er auch tatsächlich eine Berechtigung hat. +# "logs" ist eine Gruppe mit Untermenü (Live-Log + Änderungslog). +# --------------------------------------------------------------------------- +NAV_ITEMS = [ + {"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"}, + {"key": "devices", "label": "Geräte", "icon": "cpu", "endpoint": "devices"}, + {"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"}, + {"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"}, + {"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"}, + {"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"}, + {"key": "logs", "label": "Logs", "icon": "terminal", "children": [ + {"key": "logs_live", "label": "Live-Log", "icon": "terminal", "endpoint": "logs"}, + {"key": "logs_activity", "label": "Änderungslog", "icon": "history", "endpoint": "activity_log"}, + ]}, + {"key": "settings", "label": "Einstellungen", "icon": "sliders", "endpoint": "settings"}, +] +DEFAULT_NAV_ORDER = [item["key"] for item in NAV_ITEMS] +NAV_ITEMS_BY_KEY = {item["key"]: item for item in NAV_ITEMS} + + +def _nav_item_visible(key, user): + if key == "index": + return True + if key == "devices": + return user.can_view_devices + if key in ("switches", "credentials"): + return user.can_manage_switches + if key in ("users", "groups", "logs", "settings"): + return user.is_admin + return False + + +@app.context_processor +def inject_nav(): + if not current_user.is_authenticated: + return {} + try: + stored_order = json.loads(get_setting("nav_order", "") or "[]") + except (ValueError, TypeError): + stored_order = [] + ordered_keys = [k for k in stored_order if k in NAV_ITEMS_BY_KEY] + ordered_keys += [k for k in DEFAULT_NAV_ORDER if k not in ordered_keys] + nav_items_ordered = [NAV_ITEMS_BY_KEY[k] for k in ordered_keys if _nav_item_visible(k, current_user)] + return {"nav_items_ordered": nav_items_ordered} + class User(UserMixin): - def __init__(self, id_, username, is_admin, permissions=None, group_names=None): + def __init__(self, id_, username, is_admin, permissions=None, group_names=None, + first_name=None, last_name=None, avatar_filename=None): self.id = id_ self.username = username self.is_admin = bool(is_admin) self.permissions = permissions or set() self.group_names = group_names or "" + self.first_name = first_name + self.last_name = last_name + self.avatar_filename = avatar_filename def has_permission(self, key): return self.is_admin or key in self.permissions @@ -165,6 +223,21 @@ class User(UserMixin): "switches.view", "switches.create", "switches.edit", "switches.delete", }) + @property + def full_name(self): + parts = [p for p in (self.first_name, self.last_name) if p] + return " ".join(parts) + + @property + def display_name(self): + return self.full_name or self.username + + @property + def avatar_url(self): + if self.avatar_filename: + return url_for("static", filename=f"uploads/avatars/{self.avatar_filename}") + return None + def get_db_connection(): conn = sqlite3.connect(DB_PATH) @@ -203,12 +276,34 @@ def _ensure_schema(): if "is_default" not in existing_cols: conn.execute("ALTER TABLE groups ADD COLUMN is_default INTEGER DEFAULT 0") - # Migration: Vor-/Nachname für Benutzer nachrüsten. + # Migration: Vor-/Nachname + Profilbild für Benutzer nachrüsten. user_cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)").fetchall()} if "first_name" not in user_cols: conn.execute("ALTER TABLE users ADD COLUMN first_name TEXT") if "last_name" not in user_cols: conn.execute("ALTER TABLE users ADD COLUMN last_name TEXT") + if "avatar_filename" not in user_cols: + conn.execute("ALTER TABLE users ADD COLUMN avatar_filename TEXT") + + # Änderungsverlauf (Audit-Log): wer hat wann was geändert. Bewusst ohne + # PoE-Neustarts (die stehen bereits im Live-Log von poe.sh). + conn.execute(""" + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + username TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT, + details TEXT + ) + """) + + # "Wer hat zuletzt geändert" direkt am Objekt, zusätzlich zum Audit-Log. + device_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()} + if "last_modified_by" not in device_cols: + conn.execute("ALTER TABLE devices ADD COLUMN last_modified_by TEXT") + if "last_modified_at" not in device_cols: + conn.execute("ALTER TABLE devices ADD COLUMN last_modified_at TEXT") # Zugangsdaten (Credentials): eigenständige, wiederverwendbare # SSH-Logins für Switche, statt Username/Passwort direkt am Switch. @@ -223,6 +318,10 @@ def _ensure_schema(): switch_cols = {row["name"] for row in conn.execute("PRAGMA table_info(switches)").fetchall()} if "credential_id" not in switch_cols: conn.execute("ALTER TABLE switches ADD COLUMN credential_id INTEGER") + if "last_modified_by" not in switch_cols: + conn.execute("ALTER TABLE switches ADD COLUMN last_modified_by TEXT") + if "last_modified_at" not in switch_cols: + conn.execute("ALTER TABLE switches ADD COLUMN last_modified_at TEXT") # Migration: bestehende, direkt am Switch hinterlegte Zugangsdaten # (ältere DB-Version) in eigene Credentials-Datensätze überführen. @@ -305,7 +404,44 @@ def _build_user(row): conn.close() group_names = ", ".join(g["name"] for g in group_rows) - return User(row["id"], row["username"], row["is_admin"], permissions, group_names) + return User( + row["id"], row["username"], row["is_admin"], permissions, group_names, + first_name=row["first_name"] if "first_name" in row.keys() else None, + last_name=row["last_name"] if "last_name" in row.keys() else None, + avatar_filename=row["avatar_filename"] if "avatar_filename" in row.keys() else None, + ) + + +def log_action(action, target=None, details=None): + """Schreibt einen Eintrag ins Änderungsverlauf-Log. Absichtlich NICHT für + PoE-Neustarts genutzt (die stehen bereits im Live-Log von poe.sh).""" + who = current_user.username if current_user.is_authenticated else "system" + conn = get_db_connection() + conn.execute( + "INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)", + (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), who, action, target, details), + ) + conn.commit() + conn.close() + + +def touch_record(conn, table, key_col, key_val): + """Trägt 'zuletzt geändert von/am' direkt am Datensatz ein (Devices/Switches).""" + who = current_user.username if current_user.is_authenticated else "system" + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + conn.execute( + f"UPDATE {table} SET last_modified_by=?, last_modified_at=? WHERE {key_col}=?", + (who, now, key_val), + ) + + +def _derive_export_fernet(passphrase: str, salt: bytes) -> Fernet: + """Leitet aus einer Passphrase + Salt einen Fernet-Schlüssel ab (PBKDF2) — + für portable, umgebungsunabhängige Ver-/Entschlüsselung beim Import/Export + (im Gegensatz zum eigentlichen fernet.key, der pro Installation neu ist).""" + kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=390000) + key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8"))) + return Fernet(key) def encrypt_password(password: str) -> str: @@ -370,6 +506,71 @@ def logout(): return redirect(url_for("login")) +# --------------------------------------------------------------------------- +# Eigenes Profil — jeder eingeloggte Benutzer darf Vor-/Nachname, eigenes +# Passwort und Profilbild selbst ändern (Klick auf den eigenen Namen in der +# Sidebar). Bewusst getrennt vom Admin-"Users"-Bereich, der andere Benutzer +# verwaltet. +# --------------------------------------------------------------------------- + +@app.route("/profile", methods=["POST"]) +@login_required +def profile(): + conn = get_db_connection() + + if "update_profile" in request.form: + first_name = request.form.get("first_name", "").strip() or None + last_name = request.form.get("last_name", "").strip() or None + conn.execute( + "UPDATE users SET first_name=?, last_name=? WHERE id=?", + (first_name, last_name, current_user.id), + ) + conn.commit() + log_action("profile.update", current_user.username) + flash("Profil aktualisiert.", "success") + + elif "change_password" in request.form: + current_password = request.form.get("current_password", "") + new_password = request.form.get("new_password", "") + confirm_password = request.form.get("confirm_password", "") + row = conn.execute("SELECT password FROM users WHERE id=?", (current_user.id,)).fetchone() + if not row or not bcrypt.check_password_hash(row["password"], current_password): + flash("Aktuelles Passwort ist falsch.", "danger") + elif not new_password or new_password != confirm_password: + flash("Neues Passwort und Bestätigung stimmen nicht überein.", "danger") + else: + pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8") + conn.execute("UPDATE users SET password=? WHERE id=?", (pw_hash, current_user.id)) + conn.commit() + log_action("profile.password", current_user.username) + flash("Passwort geändert.", "success") + + elif "upload_avatar" in request.form: + file = request.files.get("avatar") + if not file or not file.filename: + flash("Bitte ein Bild auswählen.", "danger") + else: + ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "" + if ext not in ALLOWED_AVATAR_EXT: + flash("Nur PNG, JPG, GIF oder WEBP erlaubt.", "danger") + else: + old_row = conn.execute( + "SELECT avatar_filename FROM users WHERE id=?", (current_user.id,) + ).fetchone() + filename = secure_filename(f"user_{current_user.id}_{secrets.token_hex(4)}.{ext}") + file.save(os.path.join(AVATAR_DIR, filename)) + conn.execute("UPDATE users SET avatar_filename=? WHERE id=?", (filename, current_user.id)) + conn.commit() + if old_row and old_row["avatar_filename"]: + old_path = os.path.join(AVATAR_DIR, old_row["avatar_filename"]) + if os.path.isfile(old_path): + os.remove(old_path) + flash("Profilbild aktualisiert.", "success") + + conn.close() + return redirect(request.referrer or url_for("index")) + + # --------------------------------------------------------------------------- # Log-Auswertung (Erreichbarkeits-Status je Gerät) # --------------------------------------------------------------------------- @@ -544,6 +745,7 @@ def settings(): new_interval = int(request.form["interval"]) set_setting("interval", new_interval) set_setting("check_interval", new_interval * 60) + log_action("settings.update", "Prüfintervall", f"{new_interval} Minuten") try: subprocess.run(["systemctl", "restart", "rpi-check.service"], check=False) @@ -556,6 +758,149 @@ def settings(): return render_template("settings.html", interval=interval) +@app.route("/settings/nav-order", methods=["POST"]) +@login_required +def save_nav_order(): + if not current_user.is_admin: + flash("Nur Admins dürfen die Navbar-Reihenfolge ändern!", "danger") + return redirect(url_for("index")) + + order = [k for k in request.form.getlist("nav_order") if k in NAV_ITEMS_BY_KEY] + order += [k for k in DEFAULT_NAV_ORDER if k not in order] + set_setting("nav_order", json.dumps(order)) + log_action("settings.update", "Navbar-Reihenfolge") + flash("Navbar-Reihenfolge gespeichert.", "success") + return redirect(url_for("settings")) + + +# --------------------------------------------------------------------------- +# Import/Export — für Umzug auf eine neue Umgebung. Der komplette Datensatz +# (Devices, Switche, Zugangsdaten inkl. Passwörter) wird als ein einziges, +# mit einer selbstgewählten Passphrase verschlüsseltes JSON-Bundle exportiert +# — unabhängig vom fernet.key dieser Installation, damit die Datei auf jedem +# anderen Server wieder eingelesen werden kann, der dieselbe Passphrase kennt. +# --------------------------------------------------------------------------- + +@app.route("/settings/export", methods=["POST"]) +@login_required +def export_data(): + if not current_user.is_admin: + flash("Nur Admins dürfen Daten exportieren!", "danger") + return redirect(url_for("index")) + + passphrase = request.form.get("export_passphrase", "") + if not passphrase: + flash("Bitte eine Passphrase für den Export angeben.", "danger") + return redirect(url_for("settings")) + + conn = get_db_connection() + credentials_rows = conn.execute("SELECT name, username, password FROM credentials").fetchall() + switches_rows = conn.execute(""" + SELECT switches.hostname, switches.ip, credentials.name AS credential_name + FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id + """).fetchall() + devices_rows = conn.execute( + "SELECT mac, rpi_ip, port, name, switch_hostname, is_active FROM devices" + ).fetchall() + conn.close() + + payload = { + "credentials": [ + {"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"])} + for r in credentials_rows + ], + "switches": [ + {"hostname": r["hostname"], "ip": r["ip"], "credential_name": r["credential_name"]} + for r in switches_rows + ], + "devices": [dict(r) for r in devices_rows], + } + + salt = secrets.token_bytes(16) + export_fernet = _derive_export_fernet(passphrase, salt) + encrypted_payload = export_fernet.encrypt(json.dumps(payload).encode("utf-8")) + + envelope = { + "app": "poe-manager", + "version": 1, + "exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "salt": base64.urlsafe_b64encode(salt).decode("utf-8"), + "payload": encrypted_payload.decode("utf-8"), + } + + log_action( + "data.export", "Devices/Switches/Zugangsdaten", + f"{len(payload['devices'])} Geräte, {len(payload['switches'])} Switche, {len(payload['credentials'])} Zugangsdaten", + ) + + filename = f"poe_manager_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + response = jsonify(envelope) + response.headers["Content-Disposition"] = f"attachment; filename={filename}" + return response + + +@app.route("/settings/import", methods=["POST"]) +@login_required +def import_data(): + if not current_user.is_admin: + flash("Nur Admins dürfen Daten importieren!", "danger") + return redirect(url_for("index")) + + passphrase = request.form.get("import_passphrase", "") + upload = request.files.get("import_file") + if not passphrase or not upload or not upload.filename: + flash("Bitte Passphrase und Export-Datei angeben.", "danger") + return redirect(url_for("settings")) + + try: + envelope = json.loads(upload.read().decode("utf-8")) + salt = base64.urlsafe_b64decode(envelope["salt"]) + export_fernet = _derive_export_fernet(passphrase, salt) + payload = json.loads(export_fernet.decrypt(envelope["payload"].encode("utf-8")).decode("utf-8")) + except Exception: + flash("Import fehlgeschlagen: Datei ungültig oder Passphrase falsch.", "danger") + return redirect(url_for("settings")) + + conn = get_db_connection() + n_cred = n_switch = n_dev = 0 + + for c in payload.get("credentials", []): + conn.execute( + """INSERT INTO credentials (name, username, password) VALUES (?, ?, ?) + ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password""", + (c["name"], c["username"], encrypt_password(c["password"])), + ) + n_cred += 1 + + for s in payload.get("switches", []): + cred_id = None + if s.get("credential_name"): + row = conn.execute("SELECT id FROM credentials WHERE name=?", (s["credential_name"],)).fetchone() + cred_id = row["id"] if row else None + conn.execute( + """INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?) + ON CONFLICT(hostname) DO UPDATE SET ip=excluded.ip, credential_id=excluded.credential_id""", + (s["hostname"], s["ip"], cred_id), + ) + n_switch += 1 + + for d in payload.get("devices", []): + conn.execute( + """INSERT INTO devices (mac, rpi_ip, port, name, switch_hostname, is_active) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(mac) DO UPDATE SET rpi_ip=excluded.rpi_ip, port=excluded.port, name=excluded.name, + switch_hostname=excluded.switch_hostname, is_active=excluded.is_active""", + (d["mac"], d["rpi_ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1)), + ) + n_dev += 1 + + conn.commit() + conn.close() + + log_action("data.import", "Devices/Switches/Zugangsdaten", f"{n_dev} Geräte, {n_switch} Switche, {n_cred} Zugangsdaten") + flash(f"Import abgeschlossen: {n_cred} Zugangsdaten, {n_switch} Switche, {n_dev} Geräte.", "success") + return redirect(url_for("settings")) + + # --------------------------------------------------------------------------- # Devices # --------------------------------------------------------------------------- @@ -601,7 +946,9 @@ def devices(): "VALUES (?, ?, ?, ?, ?, ?)", (mac, rpi_ip, port, name, switch_hostname, is_active), ) + touch_record(conn, "devices", "mac", mac) conn.commit() + log_action("device.create", name, f"MAC {mac}, IP {rpi_ip}") flash(f"Gerät {name} hinzugefügt.", "success") except sqlite3.IntegrityError: flash("Fehler beim Hinzufügen des Geräts!", "danger") @@ -632,7 +979,9 @@ def devices(): conn.execute( "UPDATE devices SET switch_hostname=? WHERE mac=?", (new_switch, old_mac) ) + touch_record(conn, "devices", "mac", old_mac) conn.commit() + log_action("device.edit", device["name"], f"Switch: {old_switch} → {new_switch or 'Kein Switch'}") flash( f"Switch von {device['name']} geändert: {old_switch} → {new_switch or 'Kein Switch'}", "success", @@ -661,7 +1010,9 @@ def devices(): "UPDATE devices SET mac=?, rpi_ip=?, port=?, name=? WHERE mac=?", (mac, rpi_ip, port, name, old_mac), ) + touch_record(conn, "devices", "mac", mac) conn.commit() + log_action("device.edit", name, f"MAC {old_mac} → {mac}, IP {rpi_ip}") flash(f"Gerät {name} aktualisiert.", "success") except sqlite3.IntegrityError: flash("Fehler beim Aktualisieren des Geräts!", "danger") @@ -677,6 +1028,7 @@ def devices(): name = device["name"] if device else del_mac conn.execute("DELETE FROM devices WHERE mac=?", (del_mac,)) conn.commit() + log_action("device.delete", name, f"MAC {del_mac}") flash(f"Gerät {name} gelöscht.", "success") else: flash("Keine MAC-Adresse übermittelt!", "danger") @@ -709,10 +1061,12 @@ def toggle_device(mac): new_status = 0 if device["is_active"] else 1 conn.execute("UPDATE devices SET is_active=? WHERE mac=?", (new_status, mac)) + touch_record(conn, "devices", "mac", mac) conn.commit() conn.close() status_text = "deaktiviert" if new_status == 0 else "aktiviert" + log_action("device.deactivate" if new_status == 0 else "device.activate", device["name"]) return {"success": True, "msg": f"Gerät {device['name']} wurde {status_text}.", "new_status": new_status} @@ -772,7 +1126,9 @@ def switches(): "INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?)", (hostname, ip, credential_id), ) + touch_record(conn, "switches", "hostname", hostname) conn.commit() + log_action("switch.create", hostname, f"IP {ip}") flash(f"Switch {hostname} hinzugefügt.", "success") except sqlite3.IntegrityError: flash("Hostname existiert bereits oder Eingabefehler!", "danger") @@ -798,7 +1154,9 @@ def switches(): "UPDATE devices SET switch_hostname=? WHERE switch_hostname=?", (hostname, old_hostname), ) + touch_record(conn, "switches", "hostname", hostname) conn.commit() + log_action("switch.edit", hostname, f"IP {ip}") flash(f"Switch {hostname} aktualisiert.", "success") except sqlite3.IntegrityError: flash("Hostname existiert bereits oder Eingabefehler!", "danger") @@ -833,6 +1191,7 @@ def delete_switch(hostname): conn.execute("DELETE FROM switches WHERE hostname=?", (hostname,)) conn.commit() conn.close() + log_action("switch.delete", hostname) flash(f"Switch '{hostname}' gelöscht.", "success") return redirect(url_for("switches")) @@ -866,6 +1225,7 @@ def credentials(): (name, username, encrypt_password(password)), ) conn.commit() + log_action("credential.create", name) flash(f"Zugangsdaten '{name}' angelegt.", "success") except sqlite3.IntegrityError: flash("Es existieren bereits Zugangsdaten mit diesem Namen!", "danger") @@ -893,6 +1253,7 @@ def credentials(): (name, username, cred_id), ) conn.commit() + log_action("credential.edit", name) flash(f"Zugangsdaten '{name}' aktualisiert.", "success") except sqlite3.IntegrityError: flash("Es existieren bereits Zugangsdaten mit diesem Namen!", "danger") @@ -909,8 +1270,10 @@ def credentials(): names = ", ".join(s["hostname"] for s in used_by) flash(f"Diese Zugangsdaten werden noch von folgenden Switchen verwendet: {names}", "danger") else: + cred_row = conn.execute("SELECT name FROM credentials WHERE id=?", (cred_id,)).fetchone() conn.execute("DELETE FROM credentials WHERE id=?", (cred_id,)) conn.commit() + log_action("credential.delete", cred_row["name"] if cred_row else cred_id) flash("Zugangsdaten gelöscht.", "success") credential_rows = conn.execute(""" @@ -1184,6 +1547,27 @@ def logs(): ) +@app.route("/logs/aenderungen") +@login_required +def activity_log(): + """Änderungslog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen, + Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts — die stehen im Live-Log.""" + if not current_user.is_admin: + flash("Nur Admins dürfen das Änderungslog einsehen.", "danger") + return redirect(url_for("index")) + + conn = get_db_connection() + entries = conn.execute( + "SELECT id, ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT 500" + ).fetchall() + avatars = { + row["username"]: row["avatar_filename"] + for row in conn.execute("SELECT username, avatar_filename FROM users").fetchall() + } + conn.close() + return render_template("activity_log.html", entries=entries, avatars=avatars) + + # --------------------------------------------------------------------------- # Manueller PoE-Neustart # --------------------------------------------------------------------------- @@ -1255,6 +1639,7 @@ def users(): (cur.lastrowid, group_choice), ) conn.commit() + log_action("user.create", username) flash(f"Benutzer '{username}' erfolgreich angelegt!", "success") except sqlite3.IntegrityError: flash("Benutzername existiert bereits!", "danger") @@ -1282,6 +1667,7 @@ def users(): (username, first_name, last_name, user_id), ) conn.commit() + log_action("user.edit", username) flash("Benutzer aktualisiert!", "success") else: flash("Username darf nicht leer sein!", "danger") @@ -1291,7 +1677,7 @@ def users(): choice = request.form.get("group_id") or "" # Mindestens ein Admin muss bestehen bleiben. - target = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone() + target = conn.execute("SELECT username, is_admin FROM users WHERE id=?", (user_id,)).fetchone() if target and target["is_admin"] and choice != "admin": admin_count = conn.execute("SELECT COUNT(*) AS n FROM users WHERE is_admin=1").fetchone()["n"] if admin_count <= 1: @@ -1310,13 +1696,16 @@ def users(): (user_id, choice), ) conn.commit() + log_action("user.assign_group", target["username"] if target else user_id, choice or "keine Gruppe") flash("Gruppe zugewiesen!", "success") elif "delete_user" in request.form: user_id = request.form["delete_user"] + target = conn.execute("SELECT username FROM users WHERE id=?", (user_id,)).fetchone() conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM users WHERE id=?", (user_id,)) conn.commit() + log_action("user.delete", target["username"] if target else user_id) flash("Benutzer gelöscht!", "success") users_list = conn.execute(""" @@ -1355,6 +1744,7 @@ def groups(): try: conn.execute("INSERT INTO groups (name) VALUES (?)", (name,)) conn.commit() + log_action("group.create", name) flash(f"Gruppe '{name}' angelegt.", "success") except sqlite3.IntegrityError: flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger") @@ -1396,6 +1786,12 @@ def groups(): ) conn.commit() + detail_bits = [] + if "permissions_submitted" in request.form: + detail_bits.append("Rechte geändert") + if "members_submitted" in request.form: + detail_bits.append("Mitglieder geändert") + log_action("group.edit", name, ", ".join(detail_bits) or None) flash(f"Gruppe '{name}' aktualisiert.", "success") except sqlite3.IntegrityError: flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger") @@ -1418,6 +1814,7 @@ def groups(): [(uid,) for uid in selected_admins], ) conn.commit() + log_action("group.assign_admins", "Admin", f"{len(selected_admins)} Mitglieder") flash("Admin-Zuweisung aktualisiert.", "success") elif "delete_group" in request.form: @@ -1430,6 +1827,7 @@ def groups(): conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,)) conn.execute("DELETE FROM groups WHERE id=?", (group_id,)) conn.commit() + log_action("group.delete", target["name"] if target else group_id) flash("Gruppe gelöscht.", "success") conn.close() diff --git a/srv/poe_manager/create_db.py b/srv/poe_manager/create_db.py index b848fe7..c24cdc3 100644 --- a/srv/poe_manager/create_db.py +++ b/srv/poe_manager/create_db.py @@ -27,6 +27,8 @@ CREATE TABLE IF NOT EXISTS switches ( hostname TEXT PRIMARY KEY, ip TEXT NOT NULL, credential_id INTEGER, + last_modified_by TEXT, + last_modified_at TEXT, FOREIGN KEY (credential_id) REFERENCES credentials(id) ); """) @@ -40,6 +42,8 @@ CREATE TABLE IF NOT EXISTS devices ( port TEXT, name TEXT NOT NULL, is_active INTEGER DEFAULT 1, + last_modified_by TEXT, + last_modified_at TEXT, FOREIGN KEY (switch_hostname) REFERENCES switches(hostname) ); """) @@ -52,7 +56,8 @@ CREATE TABLE IF NOT EXISTS users ( password TEXT NOT NULL, is_admin INTEGER DEFAULT 0, first_name TEXT, - last_name TEXT + last_name TEXT, + avatar_filename TEXT ); """) @@ -97,6 +102,19 @@ CREATE TABLE IF NOT EXISTS settings ( ); """) +# Änderungslog (Audit-Log) — wer hat wann was geändert. Bewusst OHNE +# PoE-Neustarts (die stehen im Live-Log von poe.sh). +c.execute(""" +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + username TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT, + details TEXT +); +""") + # Standard-Setting: Prüfintervall 5 Minuten (interval = Minuten, check_interval = Sekunden) c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5")) c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("check_interval", "300")) diff --git a/srv/poe_manager/static/css/style.css b/srv/poe_manager/static/css/style.css index e175f6f..427c961 100644 --- a/srv/poe_manager/static/css/style.css +++ b/srv/poe_manager/static/css/style.css @@ -168,6 +168,60 @@ button { font-family: inherit; } margin: 10px 8px; } +/* Nav-Gruppe mit Untermenü (z.B. "Logs" -> Live-Log / Änderungslog) */ +.nav-group-toggle { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 12px; + border: none; + background: none; + color: var(--text-dim); + font-weight: 550; + font-size: 13.5px; + font-family: inherit; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} +.nav-group-toggle svg { width: 18px; height: 18px; flex-shrink: 0; opacity: 0.85; } +.nav-group-toggle:hover { background: var(--bg-card-hover); color: var(--text); } +.nav-group-toggle .nav-chevron { margin-left: auto; width: 14px; height: 14px; transition: transform 0.15s ease; opacity: 0.6; } +.nav-group.expanded .nav-group-toggle .nav-chevron { transform: rotate(90deg); } +.nav-group.active-group .nav-group-toggle { color: var(--accent-strong); } +.nav-group-children { + display: none; + flex-direction: column; + gap: 2px; + padding-left: 30px; + margin-top: 2px; +} +.nav-group.expanded .nav-group-children { display: flex; } +.nav-group-children .nav-item { font-size: 13px; padding: 8px 12px; } + +/* Navbar-Reihenfolge (Settings) */ +.nav-order-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; +} +.nav-order-list li { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--bg-card); + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + font-size: 13.5px; + font-weight: 550; +} +.nav-order-list .nav-order-actions { display: flex; gap: 4px; } +.nav-order-list .icon-btn { width: 28px; height: 28px; } + .sidebar-footer { padding: 14px 12px 18px; border-top: 1px solid var(--border-soft); @@ -421,6 +475,25 @@ button { font-family: inherit; } .pill.disabled { background: var(--muted-dim); color: var(--muted); } .pill.admin { background: var(--accent-dim); color: var(--accent-strong); } .pill.user { background: var(--muted-dim); color: var(--text-dim); } +.pill.action-pill { background: var(--muted-dim); color: var(--text-dim); } +.pill.action-pill::before { display: none; } +.pill.action-pill svg { width: 13px; height: 13px; } + +.avatar-sm { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 50%; + object-fit: cover; + background: var(--muted-dim); + color: var(--text-dim); + font-size: 11.5px; + font-weight: 700; + flex-shrink: 0; +} +.avatar-placeholder { text-transform: uppercase; } .timer-pill { display: inline-flex; @@ -503,6 +576,7 @@ button { font-family: inherit; } .device-card[data-status="online"]::before { background: var(--success); } .device-card[data-status="offline"]::before { background: var(--danger); } .device-card[data-status="unbekannt"]::before { background: var(--warning); } +.device-card[data-status="disabled"]::before { background: var(--muted); } .device-card .dc-top { display: flex; @@ -521,6 +595,7 @@ button { font-family: inherit; } } .device-card[data-status="online"] .dc-icon { background: var(--success-dim); color: var(--success); } .device-card[data-status="offline"] .dc-icon { background: var(--danger-dim); color: var(--danger); } +.device-card[data-status="disabled"] .dc-icon { background: var(--muted-dim); color: var(--muted); } .device-card .dc-icon svg { width: 17px; height: 17px; } .device-card .dc-name { diff --git a/srv/poe_manager/static/js/app.js b/srv/poe_manager/static/js/app.js index 6eb7bce..f384a5f 100644 --- a/srv/poe_manager/static/js/app.js +++ b/srv/poe_manager/static/js/app.js @@ -206,6 +206,15 @@ } catch (e) { /* noop */ } } + /* Nav-Gruppen mit Untermenü (z.B. "Logs") auf-/zuklappen */ + function initNavGroups() { + document.querySelectorAll("[data-nav-group-toggle]").forEach((btn) => { + btn.addEventListener("click", () => { + btn.closest("[data-nav-group]").classList.toggle("expanded"); + }); + }); + } + /* ---------------- Init ---------------- */ document.addEventListener("DOMContentLoaded", function () { @@ -214,6 +223,7 @@ initModals(); initConfirmables(); initFlashedMessages(); + initNavGroups(); document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); }); diff --git a/srv/poe_manager/templates/activity_log.html b/srv/poe_manager/templates/activity_log.html new file mode 100644 index 0000000..9ae9272 --- /dev/null +++ b/srv/poe_manager/templates/activity_log.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% set active_page = "logs" %} +{% block page_title %}Änderungslog{% endblock %} +{% block page_sub %}
{% endblock %} + +{% block content %} + +{% set action_labels = { + "settings.update": "Einstellung geändert", + "device.create": "Gerät angelegt", + "device.edit": "Gerät bearbeitet", + "device.delete": "Gerät gelöscht", + "device.activate": "Gerät aktiviert", + "device.deactivate": "Gerät deaktiviert", + "switch.create": "Switch angelegt", + "switch.edit": "Switch bearbeitet", + "switch.delete": "Switch gelöscht", + "credential.create": "Zugangsdaten angelegt", + "credential.edit": "Zugangsdaten bearbeitet", + "credential.delete": "Zugangsdaten gelöscht", + "user.create": "Benutzer angelegt", + "user.edit": "Benutzer bearbeitet", + "user.delete": "Benutzer gelöscht", + "user.assign_group": "Gruppe zugewiesen", + "group.create": "Gruppe angelegt", + "group.edit": "Gruppe bearbeitet", + "group.delete": "Gruppe gelöscht", + "group.assign_admins": "Admin-Zuweisung geändert", + "profile.update": "Profil aktualisiert", + "profile.password": "Passwort geändert", +} %} +{% set action_icons = { + "delete": '| Zeitpunkt | Benutzer | Aktion | Ziel | Details | |
|---|---|---|---|---|---|
|
+ {% if avatars.get(e['username']) %}
+ |
+ {{ e['ts'] }} | +{{ e['username'] }} | ++ + + {{ action_labels.get(e['action'], e['action']) }} + + | +{{ e['target'] or '—' }} | +{{ e['details'] or '—' }} | +
| Noch keine Änderungen protokolliert. | |||||