Files
tesm/srv/poe_manager/app.py
T
alientimandClaude Sonnet 5 37dac64abc Live-Log-Fix, Sidebar-Untermenüs für Geräte/Einstellungen, eigenes Konto
- Live-Log-Bug behoben: _latest_log_file() sortierte nach Datei-ctime statt
  nach Dateiname — auf manchen Dateisystemen (u.a. unter WSL2 auf einem
  gemounteten Windows-Laufwerk) unzuverlässig und lieferte nicht immer das
  tatsächlich neueste Logfile. Sortiert jetzt wie der Rest des Codes über
  den chronologisch sortierbaren Dateinamen (rpi-YYYYMMDDHHMMSS.log).
- Navbar umstrukturiert in aufklappbare Gruppen mit Unterpunkten:
  - "Geräte": Clients (bisherige Devices-Seite), Switche, Zugangsdaten
  - "Einstellungen": Benutzer, Gruppen, Systemeinstellungen (Prüfintervall),
    Im-/Export
  - "Logs": Live, Änderungen (vormals Live-Log/Änderungslog)
  Sichtbarkeit gilt jetzt auch pro Unterpunkt: eine Gruppe erscheint nur,
  wenn mindestens ein Unterpunkt für den Benutzer sichtbar ist, und zeigt
  dann auch nur die sichtbaren Unterpunkte.
- Im-/Export als eigene Unterseite mit Export/Import nebeneinander
  (.settings-grid).
- Neue Seite "Mein Konto" (/account, erreichbar über ein Zahnrad-Symbol
  neben dem eigenen Namen in der Sidebar) ersetzt das bisherige Profil-Modal:
  Profil/Passwort/Profilbild als volle Seite, dazu für Admins die
  Navbar-Reihenfolge (aus den Systemeinstellungen hierher verschoben).

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

1926 lines
80 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, 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 base64, 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"))
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__)
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"]
# ---------------------------------------------------------------------------
# Navbar — die Reihenfolge der TOP-LEVEL-Punkte ist admin-konfigurierbar
# (siehe /account, 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 — das gilt auch pro Unterpunkt
# innerhalb einer Gruppe: eine Gruppe erscheint nur, wenn mindestens ein
# Unterpunkt sichtbar ist, und zeigt dann auch nur die sichtbaren Unterpunkte.
# ---------------------------------------------------------------------------
NAV_ITEMS = [
{"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"},
{"key": "devices_group", "label": "Geräte", "icon": "cpu", "children": [
{"key": "devices", "label": "Clients", "icon": "cpu", "endpoint": "devices"},
{"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"},
{"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"},
]},
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
{"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
{"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"},
]},
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
{"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
{"key": "logs_activity", "label": "Änderungen", "icon": "history", "endpoint": "activity_log"},
]},
]
DEFAULT_NAV_ORDER = [item["key"] for item in NAV_ITEMS]
NAV_ITEMS_BY_KEY = {item["key"]: item for item in NAV_ITEMS}
def _nav_key_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", "settings_system", "settings_importexport", "logs_live", "logs_activity"):
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 = []
for k in ordered_keys:
item = NAV_ITEMS_BY_KEY[k]
if item.get("children"):
visible_children = [c for c in item["children"] if _nav_key_visible(c["key"], current_user)]
if visible_children:
nav_items_ordered.append({**item, "children": visible_children})
elif _nav_key_visible(k, current_user):
nav_items_ordered.append(item)
return {"nav_items_ordered": nav_items_ordered}
class User(UserMixin):
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
@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",
})
@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)
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")
# 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.
conn.execute("""
CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL
)
""")
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.
if "username" in switch_cols and "password" in switch_cols:
legacy_switches = conn.execute(
"SELECT hostname, username, password FROM switches WHERE credential_id IS NULL"
).fetchall()
for sw in legacy_switches:
base_name = f"{sw['hostname']} (migriert)"
final_name, suffix = base_name, 1
while conn.execute("SELECT 1 FROM credentials WHERE name=?", (final_name,)).fetchone():
suffix += 1
final_name = f"{base_name} {suffix}"
cur = conn.execute(
"INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)",
(final_name, sw["username"], sw["password"]),
)
conn.execute(
"UPDATE switches SET credential_id=? WHERE hostname=?",
(cur.lastrowid, sw["hostname"]),
)
# 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,
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:
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"))
# ---------------------------------------------------------------------------
# Eigenes Profil / Konto — jeder eingeloggte Benutzer darf Vor-/Nachname,
# eigenes Passwort und Profilbild selbst ändern (Zahnrad-Symbol neben dem
# eigenen Namen unten in der Sidebar). Bewusst getrennt vom Admin-"Users"-
# Bereich, der andere Benutzer verwaltet. Admins konfigurieren hier
# zusätzlich die Navbar-Reihenfolge (siehe save_nav_order()).
# ---------------------------------------------------------------------------
@app.route("/account")
@login_required
def account():
return render_template("account.html")
@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)
# ---------------------------------------------------------------------------
def _latest_log_file():
"""Neuestes Logfile ermitteln. Bewusst über den Dateinamen sortiert
(rpi-YYYYMMDDHHMMSS.log ist lexikografisch = chronologisch sortierbar),
NICHT über Dateisystem-Metadaten wie ctime/mtime — die sind je nach
Dateisystem (z.B. unter WSL2 auf einem gemounteten Windows-Laufwerk)
unzuverlässig und haben in der Praxis nicht immer das tatsächlich
zuletzt geschriebene Logfile geliefert."""
log_files = glob.glob(LOG_GLOB)
if not log_files:
return None
return max(log_files)
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, in drei Abschnitten: Offline, Online,
Deaktiviert (jeweils alphabetisch) — jeweils nur gerendert, wenn nicht
leer. Ohne Login: keine deaktivierten Geräte, nur Online/Offline/Gesamt-
Statistik, keine Interaktion. Eingeloggt: alle Geräte, volle
Statistik-Kacheln, Klick-Details, manueller PoE-Neustart bzw. bei
deaktivierten Geräten eine Aktivieren-Option.
"""
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
def by_name(devs):
return sorted(devs, key=lambda d: d["name"].lower())
offline_devices = by_name(
d for d in all_devices if d["is_active"] and status.get(d["mac"]) != "online"
)
online_devices = by_name(
d for d in all_devices if d["is_active"] and status.get(d["mac"]) == "online"
)
disabled_devices = by_name(d for d in all_devices if not d["is_active"]) if is_authenticated else []
visible_devices = offline_devices + online_devices + disabled_devices
devices = offline_devices + online_devices # für Kompatibilität/Zähler
stats = {"online": len(online_devices), "offline": len(offline_devices), "total": len(devices)}
if is_authenticated:
stats["total"] = len(all_devices)
stats["disabled"] = len(disabled_devices)
last_run_epoch_ms = int(last_run_at.timestamp() * 1000) if last_run_at else None
return render_template(
"index.html",
offline_devices=offline_devices, online_devices=online_devices, disabled_devices=disabled_devices,
device_count=len(visible_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)
log_action("settings.update", "Prüfintervall", f"{new_interval} Minuten")
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)
@app.route("/settings/import-export")
@login_required
def settings_import_export():
if not current_user.is_admin:
flash("Nur Admins dürfen Daten importieren/exportieren!", "danger")
return redirect(url_for("index"))
return render_template("settings_import_export.html")
@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("account"))
# ---------------------------------------------------------------------------
# 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_import_export"))
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_import_export"))
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_import_export"))
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_import_export"))
# ---------------------------------------------------------------------------
# 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),
)
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")
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)
)
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",
)
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),
)
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")
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()
log_action("device.delete", name, f"MAC {del_mac}")
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))
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}
# ---------------------------------------------------------------------------
# Switches
# ---------------------------------------------------------------------------
def _resolve_credential_choice(conn):
"""
Liest die Zugangsdaten-Auswahl aus einem Switch-Formular: entweder eine
bestehende credential_id, oder (choice == "new") legt direkt aus dem
Switch-Formular heraus neue Zugangsdaten an — damit man beim Anlegen
eines Switches nicht zuerst zu "Zugangsdaten" wechseln muss.
Gibt (credential_id, error_message) zurück; error_message ist None bei Erfolg.
"""
choice = request.form.get("credential_choice", "")
if choice == "new":
name = request.form.get("new_credential_name", "").strip()
username = request.form.get("new_credential_username", "").strip()
password = request.form.get("new_credential_password", "")
if not (name and username and password):
return None, "Für neue Zugangsdaten müssen Name, Username und Passwort ausgefüllt sein!"
try:
cur = conn.execute(
"INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)",
(name, username, encrypt_password(password)),
)
return cur.lastrowid, None
except sqlite3.IntegrityError:
return None, "Es existieren bereits Zugangsdaten mit diesem Namen!"
elif choice.isdigit():
return int(choice), None
return None, "Bitte Zugangsdaten auswählen oder neue anlegen."
@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"]
credential_id, cred_error = _resolve_credential_choice(conn)
if cred_error:
flash(cred_error, "danger")
else:
try:
conn.execute(
"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")
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"]
credential_id, cred_error = _resolve_credential_choice(conn)
if cred_error:
flash(cred_error, "danger")
else:
try:
conn.execute(
"UPDATE switches SET hostname=?, ip=?, credential_id=? WHERE hostname=?",
(hostname, ip, credential_id, old_hostname),
)
if hostname != old_hostname:
conn.execute(
"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")
switch_rows = conn.execute("""
SELECT switches.hostname, switches.ip, switches.credential_id,
credentials.name AS credential_name, credentials.username AS credential_username
FROM switches
LEFT JOIN credentials ON credentials.id = switches.credential_id
ORDER BY switches.hostname ASC
""").fetchall()
all_credentials = conn.execute("SELECT id, name, username FROM credentials ORDER BY name ASC").fetchall()
conn.close()
return render_template("switches.html", switches=switch_rows, all_credentials=all_credentials)
@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()
log_action("switch.delete", hostname)
flash(f"Switch '{hostname}' gelöscht.", "success")
return redirect(url_for("switches"))
# ---------------------------------------------------------------------------
# Zugangsdaten (Credentials) — wiederverwendbare SSH-Logins für Switche,
# damit nicht jeder Switch sein eigenes Login braucht. Gleiche Berechtigungen
# wie Switch-Verwaltung (switches.*), da inhaltlich untrennbar davon.
# ---------------------------------------------------------------------------
@app.route("/credentials", methods=["GET", "POST"])
@login_required
def credentials():
if not current_user.can_manage_switches:
flash("Keine Berechtigung für die Zugangsdatenverwaltung.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
if request.method == "POST" and "add_credential" in request.form:
if not current_user.has_permission("switches.create"):
flash("Keine Berechtigung, Zugangsdaten anzulegen.", "danger")
return redirect(url_for("credentials"))
name = request.form.get("name", "").strip()
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if name and username and password:
try:
conn.execute(
"INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)",
(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")
else:
flash("Name, Username und Passwort müssen ausgefüllt sein!", "danger")
elif request.method == "POST" and "edit_credential" in request.form:
if not current_user.has_permission("switches.edit"):
flash("Keine Berechtigung, Zugangsdaten zu bearbeiten.", "danger")
return redirect(url_for("credentials"))
cred_id = request.form.get("credential_id")
name = request.form.get("name", "").strip()
username = request.form.get("username", "").strip()
new_password = request.form.get("password", "")
if name and username:
try:
if new_password:
conn.execute(
"UPDATE credentials SET name=?, username=?, password=? WHERE id=?",
(name, username, encrypt_password(new_password), cred_id),
)
else:
conn.execute(
"UPDATE credentials SET name=?, username=? WHERE id=?",
(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")
else:
flash("Name und Username müssen ausgefüllt sein!", "danger")
elif request.method == "POST" and "delete_credential" in request.form:
if not current_user.has_permission("switches.delete"):
flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger")
return redirect(url_for("credentials"))
cred_id = request.form.get("delete_credential")
used_by = conn.execute("SELECT hostname FROM switches WHERE credential_id=?", (cred_id,)).fetchall()
if used_by:
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("""
SELECT credentials.id, credentials.name, credentials.username,
COUNT(switches.hostname) AS switch_count
FROM credentials
LEFT JOIN switches ON switches.credential_id = credentials.id
GROUP BY credentials.id
ORDER BY credentials.name ASC
""").fetchall()
conn.close()
return render_template("credentials.html", credentials=credential_rows)
# ---------------------------------------------------------------------------
# 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
)
@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
# ---------------------------------------------------------------------------
@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()
first_name = request.form.get("first_name", "").strip() or None
last_name = request.form.get("last_name", "").strip() or None
# Die Gruppen-Auswahl entscheidet auch über die Rolle: Auswahl
# "admin" macht den Benutzer zum Admin, jede andere Auswahl ist
# eine normale Gruppe (oder keine).
group_choice = request.form.get("group_id") or ""
is_admin = 1 if group_choice == "admin" else 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, first_name, last_name) "
"VALUES (?, ?, ?, ?, ?)",
(username, pw_hash, is_admin, first_name, last_name),
)
if not is_admin and group_choice:
conn.execute(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
(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")
else:
flash("Username und Passwort dürfen nicht leer sein!", "danger")
elif "edit_user" in request.form:
# Nur Stammdaten + optional Passwort — Gruppe/Rolle wird
# ausschließlich über "Gruppe zuweisen" geändert (s.u.).
user_id = request.form["user_id"]
username = request.form.get("username", "").strip()
first_name = request.form.get("first_name", "").strip() or None
last_name = request.form.get("last_name", "").strip() or None
new_password = request.form.get("new_password", "").strip()
if username:
if new_password:
pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
conn.execute(
"UPDATE users SET username=?, first_name=?, last_name=?, password=? WHERE id=?",
(username, first_name, last_name, pw_hash, user_id),
)
else:
conn.execute(
"UPDATE users SET username=?, first_name=?, last_name=? WHERE id=?",
(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")
elif "assign_group" in request.form:
user_id = request.form["user_id"]
choice = request.form.get("group_id") or ""
# Mindestens ein Admin muss bestehen bleiben.
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:
flash("Es muss mindestens ein Admin bestehen bleiben.", "danger")
conn.close()
return redirect(url_for("users"))
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
if choice == "admin":
conn.execute("UPDATE users SET is_admin=1 WHERE id=?", (user_id,))
else:
conn.execute("UPDATE users SET is_admin=0 WHERE id=?", (user_id,))
if choice:
conn.execute(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
(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("""
SELECT users.id, users.username, users.is_admin, users.first_name, users.last_name,
GROUP_CONCAT(groups.name, ', ') AS group_names,
(SELECT group_id FROM user_groups WHERE user_groups.user_id = users.id LIMIT 1) AS group_id
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()
all_groups = conn.execute("SELECT id, name, is_default FROM groups ORDER BY is_default DESC, name ASC").fetchall()
conn.close()
return render_template("users.html", users=users_list, all_groups=all_groups)
# ---------------------------------------------------------------------------
# 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()
log_action("group.create", name)
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()
if not name:
flash("Gruppenname darf nicht leer sein!", "danger")
conn.close()
return redirect(url_for("groups"))
# Die Gruppenkarte (Name + Rechte) und das Mitglieder-Modal sind
# zwei getrennte Formulare, damit das Speichern des einen nicht
# versehentlich den Stand des anderen zurücksetzt. Die verstecken
# *_submitted-Felder markieren, welcher Teil tatsächlich abgeschickt
# wurde (leere Checkbox-Listen wären sonst nicht von "nichts
# ausgewählt" zu unterscheiden).
try:
conn.execute("UPDATE groups SET name=? WHERE id=?", (name, group_id))
if "permissions_submitted" in request.form:
selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS)
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],
)
if "members_submitted" in request.form:
selected_members = {int(x) for x in request.form.getlist("members") if x.isdigit()}
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()
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")
elif "assign_admins" in request.form:
selected_admins = {int(x) for x in request.form.getlist("members") if x.isdigit()}
if not selected_admins:
flash("Es muss mindestens ein Admin bestehen bleiben.", "danger")
else:
conn.execute("UPDATE users SET is_admin=0")
conn.executemany(
"UPDATE users SET is_admin=1 WHERE id=?",
[(uid,) for uid in selected_admins],
)
# Admins brauchen keine Gruppenrechte mehr (sie dürfen ohnehin
# alles) — Mitgliedschaften aufräumen, damit sie nicht doppelt
# in Admin- und z.B. Standardgruppe auftauchen.
conn.executemany(
"DELETE FROM user_groups WHERE user_id=?",
[(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:
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()
log_action("group.delete", target["name"] if target else group_id)
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],
}
all_users_all = conn.execute("SELECT id, username, is_admin FROM users ORDER BY username ASC").fetchall()
conn.close()
return render_template(
"groups.html",
groups=groups_data,
admin_virtual_group=admin_virtual_group,
all_users=all_users,
all_users_all=all_users_all,
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)