- 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>
1256 lines
48 KiB
Python
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)
|