#!/usr/bin/env python3 """ PoE Manager — Web-App zur Überwachung und Steuerung von PoE-Devices über Aruba-Switche. Funktional identisch zur Ursprungsversion (Login, Dashboard, Device-/ 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_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user from flask_bcrypt import Bcrypt from flask_sock import Sock from cryptography.fernet import Fernet from datetime import datetime import sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback import paramiko class _SendAllSocketProxy: """Leitet alles an das echte Socket weiter, außer .send() -> .sendall().""" def __init__(self, sock): object.__setattr__(self, "_sock", sock) def send(self, data): return self._sock.sendall(data) def __getattr__(self, name): return getattr(self._sock, name) def _patch_simple_websocket_partial_writes(): """ simple-websocket (genutzt von Flask-Sock) schreibt WebSocket-Frames per `socket.send()`, das laut Doku aber nur *irgendeine* Anzahl Bytes schreiben muss — nicht zwingend alle. Folgen zwei Frames sehr schnell aufeinander (z.B. eine Nachricht direkt gefolgt vom Schließen der Verbindung, wie beim SSH-Terminal), kann ein unvollständig geschriebenes Frame den Frame-Stream für den Client zerstören ("Invalid frame header"). Patch: das Socket-Objekt jeder Verbindung durch einen Proxy ersetzen, dessen .send() intern .sendall() aufruft. Rein additiv/defensiv — falls die Bibliothek ihre interne Struktur ändert, wird der Patch übersprungen statt den Start zu blockieren. """ try: from simple_websocket.ws import Base original_init = Base.__init__ def patched_init(self, *args, **kwargs): original_init(self, *args, **kwargs) if self.sock is not None and not isinstance(self.sock, _SendAllSocketProxy): self.sock = _SendAllSocketProxy(self.sock) Base.__init__ = patched_init except Exception: pass _patch_simple_websocket_partial_writes() BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # --------------------------------------------------------------------------- # Konfiguration (per Umgebungsvariable überschreibbar, sinnvolle Defaults # passend zum produktiven Deployment unter /srv/poe_manager) # --------------------------------------------------------------------------- DB_PATH = os.environ.get("POE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db")) POE_SCRIPT = os.environ.get("POE_SCRIPT", "/usr/local/bin/custom/poe.sh") 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")) app = Flask(__name__) def _load_or_create_secret() -> str: env_secret = os.environ.get("POE_SECRET_KEY") if env_secret: return env_secret if os.path.exists(SECRET_KEY_PATH): with open(SECRET_KEY_PATH, "r") as f: return f.read().strip() new_secret = secrets.token_hex(32) with open(SECRET_KEY_PATH, "w") as f: f.write(new_secret) return new_secret def _load_or_create_fernet() -> Fernet: if os.path.exists(FERNET_KEY_PATH): with open(FERNET_KEY_PATH, "rb") as f: key = f.read() else: key = Fernet.generate_key() with open(FERNET_KEY_PATH, "wb") as f: f.write(key) return Fernet(key) app.secret_key = _load_or_create_secret() bcrypt = Bcrypt(app) fernet = _load_or_create_fernet() sock = Sock(app) login_manager = LoginManager() login_manager.login_view = "login" login_manager.init_app(app) # --------------------------------------------------------------------------- # Rechtesystem (Gruppen): Admins (is_admin=1) dürfen immer alles — inkl. # Benutzer-/Gruppen-/Settingsverwaltung, die es nur für Admins gibt. Für # normale Benutzer lassen sich einzelne Verwaltungsrechte über Gruppen # gezielt vergeben, ohne sie zu Admins zu machen. # --------------------------------------------------------------------------- PERMISSIONS = { "devices": { "label": "Devices", "items": { "devices.view": "Devices-Seite ansehen", "devices.toggle": "Geräte aktivieren/deaktivieren", "devices.create": "Geräte anlegen", "devices.edit": "Geräte bearbeiten (inkl. Switch-Zuordnung)", "devices.delete": "Geräte löschen", "devices.restart": "PoE-Neustart über das Dashboard", }, }, "switches": { "label": "Switches", "items": { "switches.view": "Switches-Seite ansehen", "switches.create": "Switche anlegen", "switches.edit": "Switche bearbeiten", "switches.delete": "Switche löschen", }, }, } 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"] class User(UserMixin): def __init__(self, id_, username, is_admin, permissions=None, group_names=None): self.id = id_ self.username = username self.is_admin = bool(is_admin) self.permissions = permissions or set() self.group_names = group_names or "" def has_permission(self, key): return self.is_admin or key in self.permissions @property def can_view_devices(self): return self.is_admin or "devices.view" in self.permissions @property def can_manage_switches(self): return self.is_admin or bool(self.permissions & { "switches.view", "switches.create", "switches.edit", "switches.delete", }) def get_db_connection(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def _ensure_schema(): """Legt neue Tabellen (Gruppen/Rechte) an, falls die DB noch aus einer älteren Version stammt — idempotent, sicher bei jedem Start aufzurufen. Sorgt außerdem dafür, dass die Standardgruppe 'Benutzer' existiert und jeder Benutzer ohne Gruppe ihr zugeordnet ist.""" conn = get_db_connection() conn.execute("CREATE TABLE IF NOT EXISTS groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL)") conn.execute(""" CREATE TABLE IF NOT EXISTS group_permissions ( group_id INTEGER NOT NULL, permission TEXT NOT NULL, PRIMARY KEY (group_id, permission), FOREIGN KEY (group_id) REFERENCES groups(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS user_groups ( user_id INTEGER NOT NULL, group_id INTEGER NOT NULL, PRIMARY KEY (user_id, group_id), FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (group_id) REFERENCES groups(id) ) """) # Migration: 'is_default'-Spalte nachrüsten, falls die Tabelle noch aus # einer älteren Version ohne diese Spalte stammt. existing_cols = {row["name"] for row in conn.execute("PRAGMA table_info(groups)").fetchall()} if "is_default" not in existing_cols: conn.execute("ALTER TABLE groups ADD COLUMN is_default INTEGER DEFAULT 0") # Standardgruppe 'Benutzer' sicherstellen. Die View-Rechte werden nur bei # der *erstmaligen* Erzeugung gesetzt — spätere Anpassungen durch einen # Admin (z.B. ein Recht wieder entziehen) bleiben so über Neustarts hinweg # erhalten, statt bei jedem Start erneut hineinkopiert zu werden. cur = conn.execute("INSERT OR IGNORE INTO groups (name, is_default) VALUES (?, 1)", (DEFAULT_GROUP_NAME,)) if cur.rowcount > 0: default_group_id = cur.lastrowid conn.executemany( "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)", [(default_group_id, p) for p in DEFAULT_GROUP_PERMISSIONS], ) else: conn.execute("UPDATE groups SET is_default=1 WHERE name=? AND is_default=0", (DEFAULT_GROUP_NAME,)) default_group_id = conn.execute( "SELECT id FROM groups WHERE name=?", (DEFAULT_GROUP_NAME,) ).fetchone()["id"] # Jeden nicht-admin Benutzer ohne jegliche Gruppenzugehörigkeit der # Standardgruppe zuordnen (Migrationsfall: bestehende Benutzer sollen # durch die Einführung des Rechtesystems keinen Zugriff verlieren). orphan_users = conn.execute(""" SELECT users.id FROM users LEFT JOIN user_groups ON user_groups.user_id = users.id WHERE users.is_admin = 0 AND user_groups.user_id IS NULL """).fetchall() conn.executemany( "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", [(u["id"], default_group_id) for u in orphan_users], ) conn.commit() conn.close() _ensure_schema() def _build_user(row): """Baut ein vollständiges User-Objekt inkl. effektiver Rechte aus allen Gruppen, denen der Benutzer angehört (Vereinigung, nicht Schnittmenge).""" conn = get_db_connection() group_rows = conn.execute(""" SELECT g.id, g.name FROM groups g JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = ? ORDER BY g.name ASC """, (row["id"],)).fetchall() permissions = set() if group_rows: placeholders = ",".join("?" * len(group_rows)) perm_rows = conn.execute( f"SELECT DISTINCT permission FROM group_permissions WHERE group_id IN ({placeholders})", [g["id"] for g in group_rows], ).fetchall() permissions = {p["permission"] for p in perm_rows} conn.close() group_names = ", ".join(g["name"] for g in group_rows) return User(row["id"], row["username"], row["is_admin"], permissions, group_names) def encrypt_password(password: str) -> str: return fernet.encrypt(password.encode()).decode() def decrypt_password(token: str) -> str: return fernet.decrypt(token.encode()).decode() def get_setting(key, default=None): conn = get_db_connection() row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() conn.close() return row["value"] if row else default def set_setting(key, value): conn = get_db_connection() conn.execute( "INSERT INTO settings (key, value) VALUES (?, ?) " "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value), ) conn.commit() conn.close() @login_manager.user_loader def load_user(user_id): conn = get_db_connection() user = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() conn.close() if user: return _build_user(user) return None # --------------------------------------------------------------------------- # Auth # --------------------------------------------------------------------------- @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": username = request.form["username"] password = request.form["password"] conn = get_db_connection() user = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone() conn.close() if user and bcrypt.check_password_hash(user["password"], password): login_user(_build_user(user)) return redirect(url_for("index")) flash("Ungültiger Benutzername oder Passwort", "danger") return render_template("login.html") @app.route("/logout") @login_required def logout(): logout_user() return redirect(url_for("login")) # --------------------------------------------------------------------------- # Log-Auswertung (Erreichbarkeits-Status je Gerät) # --------------------------------------------------------------------------- def _latest_log_file(): log_files = glob.glob(LOG_GLOB) if not log_files: return None return max(log_files, key=os.path.getctime) def get_last_seen(dev_name: str): """Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war.""" log_files = glob.glob(LOG_GLOB) if not log_files: return None latest_time = None for logfile in sorted(log_files): try: with open(logfile, "r") as f: for line in f: line = line.strip() if f"{dev_name} ist erreichbar!" in line: try: ts_str = line.split(" ")[0] + " " + line.split(" ")[1] ts = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S") if latest_time is None or ts > latest_time: latest_time = ts except Exception: continue except OSError: continue if latest_time: return latest_time.strftime("Zuletzt Online am %d.%m.%Y um %H:%M Uhr") return None _LOG_LINE_RE = re.compile( r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (.+?) ist (erreichbar|nicht erreichbar)!$" ) def _format_ts(ts_str: str) -> str: try: return datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").strftime("%d.%m.%Y %H:%M") except ValueError: return ts_str def get_device_status(devices): """ Ermittelt aus dem aktuellsten Logfile in einem Durchlauf je Gerät: - status: online / offline / unbekannt - last_checked: Zeitpunkt der letzten Prüfung (unabhängig vom Ergebnis) - last_seen: Zeitpunkt des letzten "online"-Status (nur für aktuell offline Geräte, über alle Logfiles hinweg — siehe get_last_seen) - last_run_at: Zeitpunkt (datetime) des letzten Prüf-Durchlaufs insgesamt (jüngste Zeile im Logfile) — Basis für den Countdown "Nächste Prüfung". """ status_dict, last_seen_dict, last_checked_dict = {}, {}, {} latest_log = _latest_log_file() if not latest_log: return status_dict, last_seen_dict, last_checked_dict, None name_to_mac = {d["name"]: d["mac"] for d in devices} last_line_for_name = {} latest_ts_str = None with open(latest_log, "r") as f: for line in f: m = _LOG_LINE_RE.match(line.strip()) if not m: continue ts_str, name, state = m.groups() if latest_ts_str is None or ts_str > latest_ts_str: latest_ts_str = ts_str if name in name_to_mac: last_line_for_name[name] = (ts_str, state == "erreichbar") for dev in devices: entry = last_line_for_name.get(dev["name"]) if entry: ts_str, is_online = entry status_dict[dev["mac"]] = "online" if is_online else "offline" last_checked_dict[dev["mac"]] = _format_ts(ts_str) if not is_online: last_seen_dict[dev["mac"]] = get_last_seen(dev["name"]) else: status_dict[dev["mac"]] = "unbekannt" last_run_at = None if latest_ts_str: try: last_run_at = datetime.strptime(latest_ts_str, "%Y-%m-%d %H:%M:%S") except ValueError: last_run_at = None return status_dict, last_seen_dict, last_checked_dict, last_run_at # --------------------------------------------------------------------------- # Dashboard # --------------------------------------------------------------------------- @app.route("/") def index(): """ Dashboard als Kachel-Ansicht. Ohne Login: nur aktive (nicht deaktivierte) Geräte, nur Online/Offline/Gesamt-Statistik, keine Interaktion. Eingeloggt: alle Geräte (inkl. deaktivierte), volle Statistik-Kacheln, Klick-Details und manueller PoE-Neustart. In beiden Fällen: erst alle Nicht-Online-Geräte (alphabetisch), danach alle Online-Geräte (alphabetisch). """ conn = get_db_connection() all_devices = conn.execute( "SELECT mac, name, rpi_ip, switch_hostname, port, is_active FROM devices ORDER BY name ASC" ).fetchall() conn.close() interval = int(get_setting("interval", 5)) status, last_seen, last_checked, last_run_at = get_device_status(all_devices) is_authenticated = current_user.is_authenticated visible_devices = all_devices if is_authenticated else [d for d in all_devices if d["is_active"]] def sort_key(d): is_online = bool(d["is_active"]) and status.get(d["mac"]) == "online" return (1 if is_online else 0, d["name"].lower()) devices = sorted(visible_devices, key=sort_key) online = sum(1 for d in visible_devices if d["is_active"] and status.get(d["mac"]) == "online") offline = sum(1 for d in visible_devices if d["is_active"] and status.get(d["mac"]) != "online") stats = {"online": online, "offline": offline, "total": len(visible_devices)} if is_authenticated: stats["disabled"] = sum(1 for d in all_devices if not d["is_active"]) last_run_epoch_ms = int(last_run_at.timestamp() * 1000) if last_run_at else None return render_template( "index.html", devices=devices, status=status, last_seen=last_seen, last_checked=last_checked, interval=interval, stats=stats, last_run_epoch_ms=last_run_epoch_ms, ) # --------------------------------------------------------------------------- # Settings # --------------------------------------------------------------------------- @app.route("/settings", methods=["GET", "POST"]) @login_required def settings(): if not current_user.is_admin: flash("Nur Admins dürfen die Einstellungen ändern!", "danger") return redirect(url_for("index")) interval = int(get_setting("interval", 5)) if request.method == "POST": new_interval = int(request.form["interval"]) set_setting("interval", new_interval) set_setting("check_interval", new_interval * 60) try: subprocess.run(["systemctl", "restart", "rpi-check.service"], check=False) except FileNotFoundError: pass # z.B. lokale Entwicklungsumgebung ohne systemd flash(f"Intervall auf {new_interval} Minuten gesetzt und Service neu gestartet!", "success") return redirect(url_for("settings")) return render_template("settings.html", interval=interval) # --------------------------------------------------------------------------- # Devices # --------------------------------------------------------------------------- @app.route("/devices", methods=["GET", "POST"]) @login_required def devices(): if request.method == "GET" and not current_user.can_view_devices: flash("Keine Berechtigung, die Devices-Seite anzusehen.", "danger") return redirect(url_for("index")) conn = get_db_connection() switches = conn.execute("SELECT hostname FROM switches ORDER BY hostname ASC").fetchall() if request.method == "POST": if "add_device" in request.form: if not current_user.has_permission("devices.create"): flash("Keine Berechtigung, Geräte anzulegen.", "danger") return redirect(url_for("devices")) mac = request.form.get("mac") rpi_ip = request.form.get("rpi_ip") port = request.form.get("port") name = request.form.get("name") switch_hostname = request.form.get("switch_hostname") or None is_active = 1 if "is_active" in request.form else 0 if not all([mac, rpi_ip, name]): flash("Alle Felder müssen ausgefüllt sein!", "danger") return redirect(url_for("devices")) if conn.execute("SELECT name FROM devices WHERE rpi_ip=?", (rpi_ip,)).fetchone(): flash("IP-Adresse existiert bereits für ein anderes Gerät!", "danger") return redirect(url_for("devices")) if conn.execute("SELECT name FROM devices WHERE mac=?", (mac,)).fetchone(): flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger") return redirect(url_for("devices")) try: conn.execute( "INSERT INTO devices (mac, rpi_ip, port, name, switch_hostname, is_active) " "VALUES (?, ?, ?, ?, ?, ?)", (mac, rpi_ip, port, name, switch_hostname, is_active), ) conn.commit() flash(f"Gerät {name} hinzugefügt.", "success") except sqlite3.IntegrityError: flash("Fehler beim Hinzufügen des Geräts!", "danger") elif "edit_device" in request.form: if not current_user.has_permission("devices.edit"): flash("Keine Berechtigung, Geräte zu bearbeiten.", "danger") return redirect(url_for("devices")) old_mac = request.form.get("old_mac") mac = request.form.get("mac") rpi_ip = request.form.get("rpi_ip") port = request.form.get("port") or None name = request.form.get("name") switch_hostname_field = "switch_hostname" in request.form # --- Nur Switch-Zuordnung ändern (Modal "Switch ändern") --- if switch_hostname_field and not any([mac, rpi_ip, name]): device = conn.execute( "SELECT name, switch_hostname FROM devices WHERE mac=?", (old_mac,) ).fetchone() if not device: flash("Gerät nicht gefunden!", "danger") return redirect(url_for("devices")) old_switch = device["switch_hostname"] or "kein Switch" new_switch = request.form.get("switch_hostname") or None conn.execute( "UPDATE devices SET switch_hostname=? WHERE mac=?", (new_switch, old_mac) ) conn.commit() flash( f"Switch von {device['name']} geändert: {old_switch} → {new_switch or 'Kein Switch'}", "success", ) return redirect(url_for("devices")) # --- Normales Gerät bearbeiten --- if not all([old_mac, mac, rpi_ip, name]): flash("Felder 'MAC', 'IP' und 'Name' müssen ausgefüllt sein!", "danger") return redirect(url_for("devices")) if conn.execute( "SELECT name FROM devices WHERE rpi_ip=? AND mac<>?", (rpi_ip, old_mac) ).fetchone(): flash("IP-Adresse existiert bereits für ein anderes Gerät!", "danger") return redirect(url_for("devices")) if conn.execute( "SELECT name FROM devices WHERE mac=? AND mac<>?", (mac, old_mac) ).fetchone(): flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger") return redirect(url_for("devices")) try: conn.execute( "UPDATE devices SET mac=?, rpi_ip=?, port=?, name=? WHERE mac=?", (mac, rpi_ip, port, name, old_mac), ) conn.commit() flash(f"Gerät {name} aktualisiert.", "success") except sqlite3.IntegrityError: flash("Fehler beim Aktualisieren des Geräts!", "danger") elif "delete_device" in request.form: if not current_user.has_permission("devices.delete"): flash("Keine Berechtigung, Geräte zu löschen.", "danger") return redirect(url_for("devices")) del_mac = request.form.get("delete_device") if del_mac: device = conn.execute("SELECT name FROM devices WHERE mac=?", (del_mac,)).fetchone() name = device["name"] if device else del_mac conn.execute("DELETE FROM devices WHERE mac=?", (del_mac,)) conn.commit() flash(f"Gerät {name} gelöscht.", "success") else: flash("Keine MAC-Adresse übermittelt!", "danger") conn.close() return redirect(url_for("devices")) device_rows = conn.execute(""" SELECT devices.mac, devices.rpi_ip, devices.port, devices.name, devices.is_active, switches.hostname AS switch_hostname FROM devices LEFT JOIN switches ON devices.switch_hostname = switches.hostname ORDER BY switches.hostname ASC, devices.name ASC """).fetchall() conn.close() return render_template("devices.html", devices=device_rows, switches=switches) @app.route("/devices/toggle/", methods=["POST"]) @login_required def toggle_device(mac): if not current_user.has_permission("devices.toggle"): return {"success": False, "msg": "Keine Berechtigung."}, 403 conn = get_db_connection() device = conn.execute("SELECT is_active, name FROM devices WHERE mac=?", (mac,)).fetchone() if not device: conn.close() return {"success": False, "msg": "Gerät nicht gefunden!"}, 404 new_status = 0 if device["is_active"] else 1 conn.execute("UPDATE devices SET is_active=? WHERE mac=?", (new_status, mac)) conn.commit() conn.close() status_text = "deaktiviert" if new_status == 0 else "aktiviert" return {"success": True, "msg": f"Gerät {device['name']} wurde {status_text}.", "new_status": new_status} # --------------------------------------------------------------------------- # Switches # --------------------------------------------------------------------------- @app.route("/switches", methods=["GET", "POST"]) @login_required def switches(): if not current_user.can_manage_switches: flash("Keine Berechtigung für die Switch-Verwaltung.", "danger") return redirect(url_for("index")) conn = get_db_connection() if request.method == "POST" and "add_switch" in request.form: if not current_user.has_permission("switches.create"): flash("Keine Berechtigung, Switche anzulegen.", "danger") return redirect(url_for("switches")) hostname = request.form["hostname"] ip = request.form["ip"] username = request.form["username"] password = encrypt_password(request.form["password"]) try: conn.execute( "INSERT INTO switches (hostname, ip, username, password) VALUES (?, ?, ?, ?)", (hostname, ip, username, password), ) conn.commit() flash(f"Switch {hostname} hinzugefügt.", "success") except sqlite3.IntegrityError: flash("Hostname existiert bereits oder Eingabefehler!", "danger") if request.method == "POST" and "edit_switch" in request.form: if not current_user.has_permission("switches.edit"): flash("Keine Berechtigung, Switche zu bearbeiten.", "danger") return redirect(url_for("switches")) old_hostname = request.form["old_hostname"] hostname = request.form["hostname"] ip = request.form["ip"] username = request.form["username"] new_password = request.form.get("password") try: if new_password: conn.execute( "UPDATE switches SET hostname=?, ip=?, username=?, password=? WHERE hostname=?", (hostname, ip, username, encrypt_password(new_password), old_hostname), ) else: conn.execute( "UPDATE switches SET hostname=?, ip=?, username=? WHERE hostname=?", (hostname, ip, username, old_hostname), ) if hostname != old_hostname: conn.execute( "UPDATE devices SET switch_hostname=? WHERE switch_hostname=?", (hostname, old_hostname), ) conn.commit() flash(f"Switch {hostname} aktualisiert.", "success") except sqlite3.IntegrityError: flash("Hostname existiert bereits oder Eingabefehler!", "danger") switch_rows = conn.execute("SELECT hostname, ip, username FROM switches ORDER BY hostname ASC").fetchall() conn.close() return render_template("switches.html", switches=switch_rows) @app.route("/switches/delete/", methods=["POST"]) @login_required def delete_switch(hostname): if not current_user.has_permission("switches.delete"): flash("Keine Berechtigung, Switche zu löschen.", "danger") return redirect(url_for("switches")) conn = get_db_connection() assigned = conn.execute("SELECT name FROM devices WHERE switch_hostname=?", (hostname,)).fetchall() if assigned: names = ", ".join(d["name"] for d in assigned) flash(f"Folgende Geräte sind noch dem Switch {hostname} zugewiesen: {names}", "danger") conn.close() return redirect(url_for("switches")) conn.execute("DELETE FROM switches WHERE hostname=?", (hostname,)) conn.commit() conn.close() flash(f"Switch '{hostname}' gelöscht.", "success") return redirect(url_for("switches")) # --------------------------------------------------------------------------- # Browser-SSH-Terminal — Verbindungstest beim Anlegen/Bearbeiten von Switchen. # # Öffnet eine echte interaktive SSH-Sitzung im Browser, damit Host-Keys beim # Erstkontakt bestätigt und Zugangsdaten geprüft werden können, bevor der # Switch überhaupt gespeichert ist. Implementiert mit paramiko (reines # Python) statt eines lokalen pty + System-`ssh`-Client — das läuft auf # jedem Betriebssystem (Windows, Linux, macOS), auf dem die Flask-App läuft, # und die SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut, # niemals vom Browser/Client aus. Nur für eingeloggte Admins. # --------------------------------------------------------------------------- _SSH_HOST_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9\.\-:]{0,253}$") _SSH_USER_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,64}$") def _send_and_close(ws, message): """ Nachricht senden und Verbindung schließen. Die kurze Pause dazwischen zwingt beide WebSocket-Frames auf getrennte TCP-Schreibvorgänge — ohne sie kommt es bei manchen Browsern vor, dass eine Nachricht direkt gefolgt von der Schließung der Verbindung als Frame-Fehler gewertet und die Nachricht verworfen wird, bevor sie angezeigt werden kann. """ ws.send(message) time.sleep(0.15) ws.close() class _AbortHostKey(Exception): """Wird geworfen, wenn der Nutzer einen unbekannten Host-Key ablehnt.""" class _InteractiveHostKeyPolicy(paramiko.MissingHostKeyPolicy): """ Emuliert das bekannte SSH-CLI-Verhalten bei unbekannten Host-Keys ("Are you sure you want to continue connecting?"), aber interaktiv über das Browser-Terminal statt eines lokalen TTY. """ def __init__(self, ws): self.ws = ws def missing_host_key(self, client, hostname, key): fingerprint = ":".join(f"{b:02x}" for b in key.get_fingerprint()) self.ws.send( f"\r\nDie Authentizität von Host '{hostname}' kann nicht festgestellt werden.\r\n" f"{key.get_name()}-Key-Fingerabdruck: {fingerprint}\r\n" f"Trotzdem verbinden (yes/no)? " ) if not self._read_yes_no(): raise _AbortHostKey() client.get_host_keys().add(hostname, key.get_name(), key) try: # save_host_keys() lädt die Datei intern zuerst neu (um parallele # Änderungen nicht zu überschreiben) — existiert sie noch nicht, # scheitert das mit FileNotFoundError. Leere Datei vorab anlegen. if not os.path.exists(SSH_KNOWN_HOSTS_PATH): open(SSH_KNOWN_HOSTS_PATH, "a").close() client.save_host_keys(SSH_KNOWN_HOSTS_PATH) except OSError: app.logger.error("save_host_keys failed:\n%s", traceback.format_exc()) def _read_yes_no(self): """ Liest zeichenweise vom Terminal (wie normale Tastatureingaben), bis Enter kommt, mit lokalem Echo — es existiert an dieser Stelle noch keine Remote-Shell, die das für uns übernehmen könnte. """ answer = "" while True: raw = self.ws.receive(timeout=120) if raw is None: return False try: payload = json.loads(raw) except (TypeError, ValueError): continue if payload.get("type") != "input": continue for ch in str(payload.get("data", "")): if ch in ("\r", "\n"): self.ws.send("\r\n") return answer.strip().lower() in ("yes", "y") elif ch in ("\x7f", "\b"): if answer: answer = answer[:-1] self.ws.send("\b \b") else: answer += ch self.ws.send(ch) @sock.route("/ws/ssh_terminal") def ssh_terminal(ws): # Fängt unerwartete Fehler ab und loggt sie, statt sie in flask-sock's # Verbindungs-Handling verschwinden zu lassen. try: return _ssh_terminal_inner(ws) except Exception: app.logger.error("ssh_terminal crashed:\n%s", traceback.format_exc()) raise def _ssh_terminal_inner(ws): if not current_user.is_authenticated or not current_user.is_admin: _send_and_close(ws, "\r\n\x1b[31mZugriff verweigert.\x1b[0m\r\n") return try: init = json.loads(ws.receive(timeout=10) or "{}") except Exception: ws.close() return host = str(init.get("host") or "").strip() username = str(init.get("username") or "").strip() password = str(init.get("password") or "") port = str(init.get("port") or "22").strip() if not (_SSH_HOST_RE.match(host) and _SSH_USER_RE.match(username) and port.isdigit()): _send_and_close(ws, "\r\n\x1b[31mUngültiger Host oder Benutzername.\x1b[0m\r\n") return ws.send(f"\x1b[90m→ Verbinde zu {username}@{host}:{port} …\x1b[0m\r\n") client = paramiko.SSHClient() try: client.load_host_keys(SSH_KNOWN_HOSTS_PATH) except (OSError, paramiko.SSHException): pass client.set_missing_host_key_policy(_InteractiveHostKeyPolicy(ws)) try: client.connect( hostname=host, port=int(port), username=username, password=password or None, timeout=10, banner_timeout=10, auth_timeout=15, look_for_keys=False, allow_agent=False, ) except _AbortHostKey: _send_and_close(ws, "\r\n\x1b[33mHost-Key abgelehnt — Verbindung abgebrochen.\x1b[0m\r\n") return except paramiko.AuthenticationException: _send_and_close(ws, "\r\n\x1b[31m✗ Zugangsdaten falsch (Authentifizierung fehlgeschlagen).\x1b[0m\r\n") return except (paramiko.SSHException, OSError, EOFError) as e: _send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n") return ws.send("\r\n\x1b[32m✓ Login erfolgreich — Sitzung wird geöffnet …\x1b[0m\r\n") try: channel = client.invoke_shell(term="xterm-256color", width=80, height=24) except paramiko.SSHException as e: _send_and_close(ws, f"\r\n\x1b[31m✗ Konnte keine Shell öffnen: {e}\x1b[0m\r\n") client.close() return channel.settimeout(0.0) closed = threading.Event() def pump_output(): while not closed.is_set(): try: if channel.recv_ready(): data = channel.recv(4096) if not data: break ws.send(data.decode("utf-8", errors="replace")) else: time.sleep(0.03) if channel.closed or channel.exit_status_ready(): if not channel.recv_ready(): break except (OSError, EOFError): break except Exception: break closed.set() reader = threading.Thread(target=pump_output, daemon=True) reader.start() try: while not closed.is_set(): message = ws.receive() if message is None: break try: payload = json.loads(message) except (TypeError, ValueError): continue if payload.get("type") == "input": try: channel.send(str(payload.get("data", ""))) except OSError: break elif payload.get("type") == "resize": try: channel.resize_pty(width=int(payload.get("cols", 80)), height=int(payload.get("rows", 24))) except paramiko.SSHException: pass except Exception: pass finally: closed.set() try: channel.close() except Exception: pass try: client.close() except Exception: pass # --------------------------------------------------------------------------- # Logs # --------------------------------------------------------------------------- @app.route("/get_log") @login_required def get_log(): latest_log = _latest_log_file() if not latest_log: return "Keine Logfiles gefunden." try: with open(latest_log, "r") as f: return f.read() except Exception as e: return f"Fehler beim Lesen des Logs: {e}" @app.route("/logs") @login_required def logs(): interval = int(get_setting("interval", 5)) latest_log = _latest_log_file() if not latest_log: return render_template("logs.html", log_content="Keine Logfiles gefunden.", interval=interval) try: with open(latest_log, "r") as f: log_content = f.read() except Exception as e: log_content = f"Fehler beim Lesen des Logs: {e}" return render_template( "logs.html", log_content=log_content, log_name=os.path.basename(latest_log), interval=interval ) # --------------------------------------------------------------------------- # Manueller PoE-Neustart # --------------------------------------------------------------------------- @app.route("/restart/", methods=["POST"]) @login_required def restart_device(mac): if not current_user.has_permission("devices.restart"): return jsonify({"success": False, "message": "Keine Berechtigung für den PoE-Neustart."}), 403 conn = get_db_connection() device = conn.execute("SELECT mac, name, is_active FROM devices WHERE mac=?", (mac,)).fetchone() conn.close() if not device: return jsonify({"success": False, "message": "Gerät wurde nicht gefunden."}), 404 if device["is_active"] == 0: return jsonify({"success": False, "message": f"{device['name']} ist deaktiviert."}), 400 try: subprocess.Popen( ["/bin/bash", POE_SCRIPT, "restart", mac], env={"PATH": os.path.join(BASE_DIR, "venv", "bin") + ":" + os.environ.get("PATH", "")}, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) return jsonify({"success": True, "device": device["name"]}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 # --------------------------------------------------------------------------- # Users # --------------------------------------------------------------------------- @app.route("/users", methods=["GET", "POST"]) @login_required def users(): if not current_user.is_admin: flash("Nur Admins dürfen Benutzer verwalten!", "danger") return redirect(url_for("index")) conn = get_db_connection() if request.method == "POST": if "add_user" in request.form: username = request.form["username"].strip() password = request.form["password"].strip() is_admin = int(request.form.get("is_admin", 0)) if username and password: pw_hash = bcrypt.generate_password_hash(password).decode("utf-8") try: cur = conn.execute( "INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)", (username, pw_hash, is_admin), ) # Neue, nicht-admin Benutzer landen automatisch in der # Standardgruppe 'Benutzer' (alle Ansichtsrechte). if not is_admin: default_group = conn.execute( "SELECT id FROM groups WHERE is_default=1 LIMIT 1" ).fetchone() if default_group: conn.execute( "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (cur.lastrowid, default_group["id"]), ) conn.commit() flash(f"Benutzer '{username}' erfolgreich angelegt!", "success") except sqlite3.IntegrityError: flash("Benutzername existiert bereits!", "danger") else: flash("Username und Passwort dürfen nicht leer sein!", "danger") elif "change_role" in request.form: user_id = request.form["user_id"] username = request.form.get("username", "").strip() is_admin = int(request.form.get("is_admin", 0)) if username: conn.execute( "UPDATE users SET username=?, is_admin=? WHERE id=?", (username, is_admin, user_id) ) conn.commit() flash("Rolle und Username geändert!", "success") else: flash("Username darf nicht leer sein!", "danger") elif "change_password" in request.form: user_id = request.form["user_id"] new_password = request.form.get("new_password", "").strip() if new_password: pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8") conn.execute("UPDATE users SET password=? WHERE id=?", (pw_hash, user_id)) conn.commit() flash("Passwort erfolgreich geändert!", "success") else: flash("Passwort darf nicht leer sein!", "danger") elif "delete_user" in request.form: user_id = request.form["delete_user"] conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM users WHERE id=?", (user_id,)) conn.commit() flash("Benutzer gelöscht!", "success") users_list = conn.execute(""" SELECT users.id, users.username, users.is_admin, GROUP_CONCAT(groups.name, ', ') AS group_names FROM users LEFT JOIN user_groups ON user_groups.user_id = users.id LEFT JOIN groups ON groups.id = user_groups.group_id GROUP BY users.id ORDER BY users.username ASC """).fetchall() conn.close() return render_template("users.html", users=users_list) # --------------------------------------------------------------------------- # Gruppen (Rechtesystem) — nur Admins dürfen Gruppen und deren # Berechtigungen/Mitgliedschaften verwalten. # --------------------------------------------------------------------------- @app.route("/groups", methods=["GET", "POST"]) @login_required def groups(): if not current_user.is_admin: flash("Nur Admins dürfen Gruppen verwalten!", "danger") return redirect(url_for("index")) conn = get_db_connection() if request.method == "POST": if "add_group" in request.form: name = request.form.get("name", "").strip() if name: try: conn.execute("INSERT INTO groups (name) VALUES (?)", (name,)) conn.commit() flash(f"Gruppe '{name}' angelegt.", "success") except sqlite3.IntegrityError: flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger") else: flash("Gruppenname darf nicht leer sein!", "danger") elif "save_group" in request.form: group_id = request.form.get("group_id") name = request.form.get("name", "").strip() selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS) selected_members = {int(x) for x in request.form.getlist("members") if x.isdigit()} if not name: flash("Gruppenname darf nicht leer sein!", "danger") conn.close() return redirect(url_for("groups")) try: conn.execute("UPDATE groups SET name=? WHERE id=?", (name, group_id)) conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) conn.executemany( "INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)", [(group_id, p) for p in selected_permissions], ) conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,)) conn.executemany( "INSERT INTO user_groups (user_id, group_id) VALUES (?, ?)", [(uid, group_id) for uid in selected_members], ) conn.commit() flash(f"Gruppe '{name}' aktualisiert.", "success") except sqlite3.IntegrityError: flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger") elif "delete_group" in request.form: group_id = request.form.get("delete_group") target = conn.execute("SELECT name, is_default FROM groups WHERE id=?", (group_id,)).fetchone() if target and target["is_default"]: flash(f"Die Standardgruppe '{target['name']}' kann nicht gelöscht werden.", "danger") else: conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,)) conn.execute("DELETE FROM groups WHERE id=?", (group_id,)) conn.commit() flash("Gruppe gelöscht.", "success") conn.close() return redirect(url_for("groups")) group_rows = conn.execute("SELECT id, name, is_default FROM groups ORDER BY is_default DESC, name ASC").fetchall() all_users = conn.execute("SELECT id, username FROM users WHERE is_admin=0 ORDER BY username ASC").fetchall() groups_data = [] for g in group_rows: perm_rows = conn.execute( "SELECT permission FROM group_permissions WHERE group_id=?", (g["id"],) ).fetchall() member_rows = conn.execute(""" SELECT users.id, users.username FROM users JOIN user_groups ON user_groups.user_id = users.id WHERE user_groups.group_id = ? ORDER BY users.username ASC """, (g["id"],)).fetchall() groups_data.append({ "id": g["id"], "name": g["name"], "is_default": bool(g["is_default"]), "permissions": {p["permission"] for p in perm_rows}, "members": {m["id"] for m in member_rows}, "member_names": [m["username"] for m in member_rows], }) # Virtuelle "Admin"-Gruppe: rein informativ, damit auf einen Blick # sichtbar ist, wer alles darf — Mitgliedschaft/Rechte werden weiterhin # ausschließlich über den is_admin-Schalter auf der Users-Seite gesteuert, # hier gibt es daher bewusst kein Formular. admin_rows = conn.execute("SELECT username FROM users WHERE is_admin=1 ORDER BY username ASC").fetchall() admin_virtual_group = { "name": "Admin", "permissions": set(ALL_PERMISSION_KEYS), "member_names": [u["username"] for u in admin_rows], } conn.close() return render_template( "groups.html", groups=groups_data, admin_virtual_group=admin_virtual_group, all_users=all_users, permission_catalog=PERMISSIONS, ) if __name__ == "__main__": debug = os.environ.get("FLASK_DEBUG", "0") == "1" # threaded=True ist für das SSH-Web-Terminal (WebSocket) notwendig: die # Verbindung hält den Thread offen, ohne Threading würde der einzelne # Worker der Dev-Umgebung blockieren und WebSocket-Frames verstümmeln. app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=debug, threaded=True)