- Zugangsdaten (SSH-Logins) als eigenstaendige, wiederverwendbare Entitaet statt direkt am Switch; inline Neuanlage beim Switch-Erstellen moeglich; automatische Migration bestehender Switch-Logins - Gruppen-Seite als Tabelle mit Aufklapp-Zeile fuer Rechte (Akkordeon), Admin als feste Systemzeile (Mitgliederverwaltung ueber is_admin), Standardgruppe 'Benutzer' mit allen Ansichtsrechten (devices.view, switches.view), automatische Zuordnung neuer/verwaister Benutzer - Users-Seite: Icon-Buttons statt Text, Bearbeiten+Passwortaenderung in einem Modal zusammengefuehrt, Gruppe/Admin-Zuweisung ueber eigenen Zuweisen-Button (wie Switch-Zuordnung bei Devices), Vor-/Nachname - Dashboard: einheitliche Kachelansicht mit/ohne Login, drei sortierte Abschnitte (Offline/Online/Deaktiviert), Kachel-Suchfilter, Bootstrap- artiges Grid (max. 6 Spalten), Aktivieren-Option im Popup fuer deaktivierte Geraete, Countdown serverseitig korrekt geseedet - Sidebar dauerhaft einklappbar (Desktop, persistent via localStorage) - Devices-Tabelle: Aktions-Buttons nebeneinander statt untereinander Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1490 lines
60 KiB
Python
1490 lines
60 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")
|
|
|
|
# Migration: Vor-/Nachname 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")
|
|
|
|
# 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")
|
|
|
|
# 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)
|
|
|
|
|
|
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, 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)
|
|
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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),
|
|
)
|
|
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"]
|
|
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),
|
|
)
|
|
conn.commit()
|
|
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()
|
|
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()
|
|
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()
|
|
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:
|
|
conn.execute("DELETE FROM credentials WHERE id=?", (cred_id,))
|
|
conn.commit()
|
|
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
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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()
|
|
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()
|
|
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 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()
|
|
flash("Gruppe zugewiesen!", "success")
|
|
|
|
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, 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()
|
|
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()
|
|
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()
|
|
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()
|
|
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)
|