- Änderungslog: audit_log-Tabelle + last_modified_by/at an Devices und Switches; log_action()/touch_record() in allen Verwaltungsrouten (Devices, Switches, Zugangsdaten, Benutzer, Gruppen, Settings) verdrahtet. Neue Admin-Seite "Änderungslog" unter /logs/aenderungen. PoE-Neustarts werden bewusst nicht geloggt. - Verschlüsseltes Import/Export für Devices/Switches/Zugangsdaten unter Settings, passphrasenbasiert (PBKDF2 + Fernet), für Umzug auf neue Umgebungen. Referenziert Zugangsdaten/Switche über Name/Hostname statt interner ID für stabilen Re-Import. - Eigenes Profil: Klick auf den Namen in der Sidebar öffnet ein Modal zum Ändern von Vor-/Nachname, eigenem Passwort (mit Prüfung des aktuellen Passworts) und Profilbild-Upload (Anzeige in Sidebar + Änderungslog). - Anpassbare Navigation: Reihenfolge der Sidebar-Punkte ist unter Settings per Auf-/Ab-Buttons konfigurierbar (settings.nav_order); "Devices"/ "Users"/"Settings" umbenannt zu "Geräte"/"Benutzer"/"Einstellungen"; "Live-Log" und "Änderungslog" zu aufklappbarer "Logs"-Gruppe zusammengefasst. Jeder Benutzer sieht weiterhin nur, wofür er berechtigt ist. - create_db.py mit allen Schema-Erweiterungen synchronisiert (audit_log, last_modified_by/at, avatar_filename) für Frischinstallationen. - venv-Umgebung im WSL neu aufgesetzt (war fälschlich unter dem Erstellungspfad venv-linux verankert und daher nicht aktivierbar). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1888 lines
78 KiB
Python
1888 lines
78 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 — Reihenfolge ist admin-konfigurierbar (siehe settings(), gespeichert
|
|
# als JSON-Liste von Keys unter settings.nav_order); standardmäßig wird pro
|
|
# Benutzer nur angezeigt, wofür er auch tatsächlich eine Berechtigung hat.
|
|
# "logs" ist eine Gruppe mit Untermenü (Live-Log + Änderungslog).
|
|
# ---------------------------------------------------------------------------
|
|
NAV_ITEMS = [
|
|
{"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"},
|
|
{"key": "devices", "label": "Geräte", "icon": "cpu", "endpoint": "devices"},
|
|
{"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"},
|
|
{"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"},
|
|
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
|
|
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
|
|
{"key": "logs", "label": "Logs", "icon": "terminal", "children": [
|
|
{"key": "logs_live", "label": "Live-Log", "icon": "terminal", "endpoint": "logs"},
|
|
{"key": "logs_activity", "label": "Änderungslog", "icon": "history", "endpoint": "activity_log"},
|
|
]},
|
|
{"key": "settings", "label": "Einstellungen", "icon": "sliders", "endpoint": "settings"},
|
|
]
|
|
DEFAULT_NAV_ORDER = [item["key"] for item in NAV_ITEMS]
|
|
NAV_ITEMS_BY_KEY = {item["key"]: item for item in NAV_ITEMS}
|
|
|
|
|
|
def _nav_item_visible(key, user):
|
|
if key == "index":
|
|
return True
|
|
if key == "devices":
|
|
return user.can_view_devices
|
|
if key in ("switches", "credentials"):
|
|
return user.can_manage_switches
|
|
if key in ("users", "groups", "logs", "settings"):
|
|
return user.is_admin
|
|
return False
|
|
|
|
|
|
@app.context_processor
|
|
def inject_nav():
|
|
if not current_user.is_authenticated:
|
|
return {}
|
|
try:
|
|
stored_order = json.loads(get_setting("nav_order", "") or "[]")
|
|
except (ValueError, TypeError):
|
|
stored_order = []
|
|
ordered_keys = [k for k in stored_order if k in NAV_ITEMS_BY_KEY]
|
|
ordered_keys += [k for k in DEFAULT_NAV_ORDER if k not in ordered_keys]
|
|
nav_items_ordered = [NAV_ITEMS_BY_KEY[k] for k in ordered_keys if _nav_item_visible(k, current_user)]
|
|
return {"nav_items_ordered": nav_items_ordered}
|
|
|
|
|
|
class User(UserMixin):
|
|
def __init__(self, id_, username, is_admin, permissions=None, group_names=None,
|
|
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 — jeder eingeloggte Benutzer darf Vor-/Nachname, eigenes
|
|
# Passwort und Profilbild selbst ändern (Klick auf den eigenen Namen in der
|
|
# Sidebar). Bewusst getrennt vom Admin-"Users"-Bereich, der andere Benutzer
|
|
# verwaltet.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@app.route("/profile", methods=["POST"])
|
|
@login_required
|
|
def profile():
|
|
conn = get_db_connection()
|
|
|
|
if "update_profile" in request.form:
|
|
first_name = request.form.get("first_name", "").strip() or None
|
|
last_name = request.form.get("last_name", "").strip() or None
|
|
conn.execute(
|
|
"UPDATE users SET first_name=?, last_name=? WHERE id=?",
|
|
(first_name, last_name, current_user.id),
|
|
)
|
|
conn.commit()
|
|
log_action("profile.update", current_user.username)
|
|
flash("Profil aktualisiert.", "success")
|
|
|
|
elif "change_password" in request.form:
|
|
current_password = request.form.get("current_password", "")
|
|
new_password = request.form.get("new_password", "")
|
|
confirm_password = request.form.get("confirm_password", "")
|
|
row = conn.execute("SELECT password FROM users WHERE id=?", (current_user.id,)).fetchone()
|
|
if not row or not bcrypt.check_password_hash(row["password"], current_password):
|
|
flash("Aktuelles Passwort ist falsch.", "danger")
|
|
elif not new_password or new_password != confirm_password:
|
|
flash("Neues Passwort und Bestätigung stimmen nicht überein.", "danger")
|
|
else:
|
|
pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
|
|
conn.execute("UPDATE users SET password=? WHERE id=?", (pw_hash, current_user.id))
|
|
conn.commit()
|
|
log_action("profile.password", current_user.username)
|
|
flash("Passwort geändert.", "success")
|
|
|
|
elif "upload_avatar" in request.form:
|
|
file = request.files.get("avatar")
|
|
if not file or not file.filename:
|
|
flash("Bitte ein Bild auswählen.", "danger")
|
|
else:
|
|
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
|
|
if ext not in ALLOWED_AVATAR_EXT:
|
|
flash("Nur PNG, JPG, GIF oder WEBP erlaubt.", "danger")
|
|
else:
|
|
old_row = conn.execute(
|
|
"SELECT avatar_filename FROM users WHERE id=?", (current_user.id,)
|
|
).fetchone()
|
|
filename = secure_filename(f"user_{current_user.id}_{secrets.token_hex(4)}.{ext}")
|
|
file.save(os.path.join(AVATAR_DIR, filename))
|
|
conn.execute("UPDATE users SET avatar_filename=? WHERE id=?", (filename, current_user.id))
|
|
conn.commit()
|
|
if old_row and old_row["avatar_filename"]:
|
|
old_path = os.path.join(AVATAR_DIR, old_row["avatar_filename"])
|
|
if os.path.isfile(old_path):
|
|
os.remove(old_path)
|
|
flash("Profilbild aktualisiert.", "success")
|
|
|
|
conn.close()
|
|
return redirect(request.referrer or url_for("index"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Log-Auswertung (Erreichbarkeits-Status je Gerät)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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, 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/nav-order", methods=["POST"])
|
|
@login_required
|
|
def save_nav_order():
|
|
if not current_user.is_admin:
|
|
flash("Nur Admins dürfen die Navbar-Reihenfolge ändern!", "danger")
|
|
return redirect(url_for("index"))
|
|
|
|
order = [k for k in request.form.getlist("nav_order") if k in NAV_ITEMS_BY_KEY]
|
|
order += [k for k in DEFAULT_NAV_ORDER if k not in order]
|
|
set_setting("nav_order", json.dumps(order))
|
|
log_action("settings.update", "Navbar-Reihenfolge")
|
|
flash("Navbar-Reihenfolge gespeichert.", "success")
|
|
return redirect(url_for("settings"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Import/Export — für Umzug auf eine neue Umgebung. Der komplette Datensatz
|
|
# (Devices, Switche, Zugangsdaten inkl. Passwörter) wird als ein einziges,
|
|
# mit einer selbstgewählten Passphrase verschlüsseltes JSON-Bundle exportiert
|
|
# — unabhängig vom fernet.key dieser Installation, damit die Datei auf jedem
|
|
# anderen Server wieder eingelesen werden kann, der dieselbe Passphrase kennt.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@app.route("/settings/export", methods=["POST"])
|
|
@login_required
|
|
def export_data():
|
|
if not current_user.is_admin:
|
|
flash("Nur Admins dürfen Daten exportieren!", "danger")
|
|
return redirect(url_for("index"))
|
|
|
|
passphrase = request.form.get("export_passphrase", "")
|
|
if not passphrase:
|
|
flash("Bitte eine Passphrase für den Export angeben.", "danger")
|
|
return redirect(url_for("settings"))
|
|
|
|
conn = get_db_connection()
|
|
credentials_rows = conn.execute("SELECT name, username, password FROM credentials").fetchall()
|
|
switches_rows = conn.execute("""
|
|
SELECT switches.hostname, switches.ip, credentials.name AS credential_name
|
|
FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
|
|
""").fetchall()
|
|
devices_rows = conn.execute(
|
|
"SELECT mac, rpi_ip, port, name, switch_hostname, is_active FROM devices"
|
|
).fetchall()
|
|
conn.close()
|
|
|
|
payload = {
|
|
"credentials": [
|
|
{"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"])}
|
|
for r in credentials_rows
|
|
],
|
|
"switches": [
|
|
{"hostname": r["hostname"], "ip": r["ip"], "credential_name": r["credential_name"]}
|
|
for r in switches_rows
|
|
],
|
|
"devices": [dict(r) for r in devices_rows],
|
|
}
|
|
|
|
salt = secrets.token_bytes(16)
|
|
export_fernet = _derive_export_fernet(passphrase, salt)
|
|
encrypted_payload = export_fernet.encrypt(json.dumps(payload).encode("utf-8"))
|
|
|
|
envelope = {
|
|
"app": "poe-manager",
|
|
"version": 1,
|
|
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
"salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
|
|
"payload": encrypted_payload.decode("utf-8"),
|
|
}
|
|
|
|
log_action(
|
|
"data.export", "Devices/Switches/Zugangsdaten",
|
|
f"{len(payload['devices'])} Geräte, {len(payload['switches'])} Switche, {len(payload['credentials'])} Zugangsdaten",
|
|
)
|
|
|
|
filename = f"poe_manager_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
|
response = jsonify(envelope)
|
|
response.headers["Content-Disposition"] = f"attachment; filename={filename}"
|
|
return response
|
|
|
|
|
|
@app.route("/settings/import", methods=["POST"])
|
|
@login_required
|
|
def import_data():
|
|
if not current_user.is_admin:
|
|
flash("Nur Admins dürfen Daten importieren!", "danger")
|
|
return redirect(url_for("index"))
|
|
|
|
passphrase = request.form.get("import_passphrase", "")
|
|
upload = request.files.get("import_file")
|
|
if not passphrase or not upload or not upload.filename:
|
|
flash("Bitte Passphrase und Export-Datei angeben.", "danger")
|
|
return redirect(url_for("settings"))
|
|
|
|
try:
|
|
envelope = json.loads(upload.read().decode("utf-8"))
|
|
salt = base64.urlsafe_b64decode(envelope["salt"])
|
|
export_fernet = _derive_export_fernet(passphrase, salt)
|
|
payload = json.loads(export_fernet.decrypt(envelope["payload"].encode("utf-8")).decode("utf-8"))
|
|
except Exception:
|
|
flash("Import fehlgeschlagen: Datei ungültig oder Passphrase falsch.", "danger")
|
|
return redirect(url_for("settings"))
|
|
|
|
conn = get_db_connection()
|
|
n_cred = n_switch = n_dev = 0
|
|
|
|
for c in payload.get("credentials", []):
|
|
conn.execute(
|
|
"""INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)
|
|
ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password""",
|
|
(c["name"], c["username"], encrypt_password(c["password"])),
|
|
)
|
|
n_cred += 1
|
|
|
|
for s in payload.get("switches", []):
|
|
cred_id = None
|
|
if s.get("credential_name"):
|
|
row = conn.execute("SELECT id FROM credentials WHERE name=?", (s["credential_name"],)).fetchone()
|
|
cred_id = row["id"] if row else None
|
|
conn.execute(
|
|
"""INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?)
|
|
ON CONFLICT(hostname) DO UPDATE SET ip=excluded.ip, credential_id=excluded.credential_id""",
|
|
(s["hostname"], s["ip"], cred_id),
|
|
)
|
|
n_switch += 1
|
|
|
|
for d in payload.get("devices", []):
|
|
conn.execute(
|
|
"""INSERT INTO devices (mac, rpi_ip, port, name, switch_hostname, is_active) VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(mac) DO UPDATE SET rpi_ip=excluded.rpi_ip, port=excluded.port, name=excluded.name,
|
|
switch_hostname=excluded.switch_hostname, is_active=excluded.is_active""",
|
|
(d["mac"], d["rpi_ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1)),
|
|
)
|
|
n_dev += 1
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
log_action("data.import", "Devices/Switches/Zugangsdaten", f"{n_dev} Geräte, {n_switch} Switche, {n_cred} Zugangsdaten")
|
|
flash(f"Import abgeschlossen: {n_cred} Zugangsdaten, {n_switch} Switche, {n_dev} Geräte.", "success")
|
|
return redirect(url_for("settings"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Devices
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@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)
|