Files
tesm/srv/poe_manager/app.py
T
alientimandClaude Sonnet 5 08d8d1f738 Dashboard-Kacheln, Ansichtsrechte, Standardgruppe, Countdown-Fix, Deploy-Pfad
- Dashboard zeigt jetzt in beiden Zustaenden (mit/ohne Login) Kacheln statt
  Tabelle; ohne Login nur aktive Geraete + Online/Offline/Gesamt, eingeloggt
  alle Geraete + zusaetzliche Deaktiviert-Kachel
- Sortierung ueberall: erst alle Nicht-Online-Geraete, dann Online, jeweils
  alphabetisch
- Neue Rechte devices.view / switches.view; Standardgruppe 'Benutzer' wird
  automatisch angelegt (alle Ansichtsrechte) und jedem neuen Benutzer
  zugeordnet; bestehende Benutzer ohne Gruppe werden migriert
- Gruppen-Seite zeigt zusaetzlich virtuelle 'Admin'-Karte (informativ) und
  markiert die Standardgruppe (nicht loeschbar)
- 'Naechste Pruefung'-Countdown wird serverseitig aus dem tatsaechlichen
  letzten Log-Eintrag geseedet statt bei jedem Reload neu zu starten
- Deployment-Doku korrigiert: Checkout getrennt von /srv/poe_manager, damit
  kein verschachteltes srv/poe_manager/srv/poe_manager entsteht

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 12:41:21 +02:00

1256 lines
48 KiB
Python

#!/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/<mac>", 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/<hostname>", 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/<mac>", 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)