diff --git a/README.md b/README.md index 31309f6..8d7d616 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,16 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client schreibt die vollständige, generierte Kea-JSON-Konfiguration an den konfigurierten Ausgabepfad. Wirksam wird sie erst nach einem Dienst-Neustart über den separaten Button. +- **Range ist Pflicht**: kein vorausgefüllter Default für Range/DNS mehr + (nur Platzhaltertext) — ohne eingetragene Range schreibt „In Datei + schreiben“ nichts und „Aktivieren & (neu) starten“ verweigert den Start, + statt den Dienst mit einer geratenen, zum echten Netz eventuell nicht + passenden Range laufen zu lassen. Gateway ist separat und optional + überschreibbar (Default: automatisch erkanntes Gateway des Interfaces). +- **Ampel im Topbar**: neben dem Prüfintervall-Timer zeigt ein grüner/roter + Punkt, ob der Kea-Dienst läuft — nur sichtbar mit `settings_dhcp.view`. +- Reservierungen mit aktiven eigenen Options zeigen in der Tabelle, welche + Option greift (global oder Client-Override). Live gegen eine echte, frisch installierte Kea-Instanz verifiziert: Installation, generierte Konfiguration besteht `kea-dhcp4 -t`, Dienststart @@ -274,8 +284,10 @@ Konfiguration. Eigene, klar getrennte Funktion von obigem DHCP-*Server*: hier geht es um die Netzwerkkonfiguration des Hosts, auf dem der PoE Manager selbst läuft -(IP-Adresse, DNS-Server, DHCP-Client vs. statisch) — nicht um Adressen, die -er an andere Geräte vergibt. +(IP-Adresse, DNS-Server, DHCP-Client vs. statisch, Hostname) — nicht um +Adressen, die er an andere Geräte vergibt. Der Hostname wird separat und +risikolos über `hostnamectl set-hostname` gesetzt (kein Revert-Timer nötig, +da eine Änderung nicht die Erreichbarkeit kappt). - **Backend-Erkennung**: prüft, ob NetworkManager oder dhcpcd aktiv ist (`systemctl is-active`). Ist keins von beiden aktiv, bleibt die Seite @@ -482,6 +494,13 @@ Voraussetzungen: empfiehlt es sich, `poe_web.service` künftig unter einem eigenen, unprivilegierten Systembenutzer laufen zu lassen (im Original wie auch hier aktuell `root`, um Änderungen am produktiven Setup nicht zu vergrößern). +- Rechtsklick/DevTools-Shortcuts (F12, Strg+Umschalt+I/J/C, Strg+U) sind per + JS blockiert (`initSourceProtection()` in `app.js`). Das ist eine Hürde + gegen gelegentliches Kopieren, **keine echte Sicherheit** — der Browser + liefert HTML/CSS/JS immer vollständig aus (sonst könnte er die Seite + nicht darstellen), und jede dieser Sperren lässt sich in Sekunden umgehen + (URL-Leiste, externer HTTP-Client, DevTools per Menü). Die eigentliche + Absicherung sind ausschließlich die serverseitigen Rechteprüfungen. ## Projektstruktur diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index 3bff2e3..1808356 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -17,7 +17,7 @@ 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, ipaddress, shutil, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback +import base64, ipaddress, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback import paramiko @@ -623,24 +623,12 @@ def _ensure_schema(): (cur.lastrowid, sw["hostname"]), ) - # Migration (Einführung des granularen, an die Navbar gespiegelten - # Rechtebaums): alte Permission-Keys auf die neuen übertragen, damit - # bestehende Gruppen durch das Update keinen Zugriff verlieren. - # - devices.toggle ist jetzt Teil von devices.edit (Ändern). - # - Zugangsdaten hatten bisher keine eigenen Rechte, sondern hingen an - # switches.* — bestehende Gruppen bekommen die entsprechenden - # credentials.*-Rechte gespiegelt, künftig sind beide unabhängig. - # - Jede Gruppe, die bereits ein Kind-Recht in einem Bereich hatte, - # bekommt automatisch auch das neue übergeordnete "Bereich - # sichtbar"-Recht (devices_group.view/logs_group.view/ - # settings_group.view), da Kind-Rechte jetzt ohne dieses Recht wirkungslos - # wären (siehe User.has_permission). - # WICHTIG: Läuft nur EINMALIG (Guard über settings), nicht bei jedem - # Start — sonst würde das übergeordnete "Bereich sichtbar"-Recht bei - # jedem Neustart automatisch wieder gesetzt, sobald irgendein Kind-Recht - # noch aktiv ist. Das würde den eigentlichen Zweck des Kill-Switches - # zunichtemachen (ein Admin könnte den ganzen Bereich nie dauerhaft - # sperren, ohne zuvor jedes einzelne Kind-Recht zu entfernen). + # Migration (granularer Rechtebaum): devices.toggle -> devices.edit, + # switches.* -> gespiegelte credentials.*, sowie Backfill des neuen + # "Bereich sichtbar"-Rechts für Gruppen mit bereits gesetzten Kind-Rechten. + # WICHTIG: nur EINMALIG (Guard über settings) — sonst würde das + # Bereichs-Recht bei jedem Neustart automatisch neu gesetzt und den + # Kill-Switch (siehe User.has_permission) wirkungslos machen. _migration_key = "_migrated_permission_hierarchy_v1" if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key,)).fetchone(): toggle_groups = conn.execute( @@ -1077,6 +1065,22 @@ def inject_check_timer(): } +@app.context_processor +def inject_dhcp_topbar_status(): + """Ampel-Punkt für den Kea-Dienst neben dem Prüfintervall-Timer — nur + für Benutzer mit mindestens Leserecht auf DHCP, und nur ein einzelner, + kurzer systemctl-Aufruf (nicht die volle Status-Erkennung von + _detect_dhcp_status), damit das nicht jede Seite spürbar verlangsamt.""" + if not current_user.is_authenticated or not current_user.has_permission("settings_dhcp.view"): + return {"dhcp_topbar_active": None} + try: + result = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=2) + active = result.stdout.strip() == "active" + except Exception: + active = None + return {"dhcp_topbar_active": active} + + def get_device_status(devices): """ Ermittelt aus dem aktuellsten Logfile in einem Durchlauf je Gerät: @@ -1229,21 +1233,12 @@ def run_check_now(): # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- -# System-Netzwerkeinstellungen (IP/DNS/DHCP DIESES Hosts — nicht zu -# verwechseln mit dem Kea-DHCP-*Server* für Client-Geräte weiter unten). -# -# Höchste Vorsicht geboten: eine falsche Änderung hier kann die Erreichbarkeit -# dieses Hosts (SSH/Web-UI) komplett kappen. Deshalb: -# - Erkennung des tatsächlich aktiven Netzwerk-Backends (NetworkManager -# oder dhcpcd) statt blind eine Methode anzunehmen; ist keins von beiden -# aktiv (z.B. in dieser WSL-Testumgebung), bleibt die Seite bewusst rein -# lesend statt zu raten. -# - Vor jeder Änderung wird die vorherige Konfiguration gesichert. -# - Nach dem Anwenden läuft ein Sicherheits-Timer im Hintergrund: wird die -# neue Konfiguration nicht innerhalb von NETWORK_REVERT_SECONDS explizit -# bestätigt (das ist nur möglich, wenn die Web-UI über die NEUE -# Konfiguration noch erreichbar ist), wird automatisch die gesicherte -# Konfiguration wiederhergestellt — analog zu "netplan try". +# System-Netzwerkeinstellungen (IP/DNS/DHCP DIESES Hosts — nicht der +# Kea-DHCP-*Server* für Client-Geräte weiter unten). Eine falsche Änderung +# kann die Erreichbarkeit kappen, daher: Backend-Erkennung statt Annahme +# (unbekannt -> rein lesend), Backup vor jeder Änderung, und ein +# Sicherheits-Timer (analog "netplan try") der ohne explizite Bestätigung +# innerhalb von NETWORK_REVERT_SECONDS automatisch zurückrollt. # --------------------------------------------------------------------------- NETWORK_REVERT_SECONDS = 45 @@ -1470,6 +1465,18 @@ def settings(): else: flash(f"Anwenden fehlgeschlagen: {out}", "danger") + elif "hostname" in request.form: + new_hostname = request.form.get("hostname", "").strip() + if not re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$", new_hostname): + flash("Ungültiger Hostname (nur Buchstaben, Ziffern und Bindestrich, max. 63 Zeichen).", "danger") + else: + ok, out = _dhcp_run_privileged(["hostnamectl", "set-hostname", new_hostname], timeout=10) + if ok: + log_action("settings.update", "Hostname", new_hostname) + flash(f"Hostname auf „{new_hostname}“ gesetzt.", "success") + else: + flash(f"Hostname konnte nicht gesetzt werden: {out}", "danger") + elif "confirm_network" in request.form: token = request.form.get("confirm_network") entry = _pending_network_revert.pop(token, None) @@ -1486,9 +1493,14 @@ def settings(): net_interface = get_setting("net_interface") or (_list_network_interfaces() or [None])[0] net_state = _read_network_state(net_interface, net_backend) if net_interface else None pending_token = next(iter(_pending_network_revert), None) + try: + current_hostname = socket.gethostname() + except Exception: + current_hostname = None return render_template( "settings.html", interval=interval, + current_hostname=current_hostname, net_backend=net_backend, net_interfaces=_list_network_interfaces(), net_interface=net_interface, @@ -1510,17 +1522,11 @@ def settings_import_export(): # --------------------------------------------------------------------------- # DHCP-Reservierungen (Kea DHCP) # -# Backend bewusst Kea (kea-dhcp4-server), nicht das ältere isc-dhcp-server: -# Kea wird von ISC selbst aktiv weiterentwickelt (isc-dhcp-server ist seit -# 2022 EOL) und bildet "globaler Wert, pro Client überschreibbar" nativ ab -# (Reservation-Options überschreiben in Kea automatisch gleichnamige -# globale/Subnet-Options) — genau das von den Custom-Options gefordert. -# -# Installation & Dienststeuerung sind eigene, einzeln bestätigte Aktionen -# (apt-get install / systemctl enable+restart) statt automatisch an -# "Konfiguration speichern" gekoppelt — ein unerwartet aktiver oder -# fehlkonfigurierter DHCP-Server kann ein ganzes Netzsegment stören, das -# soll immer ein bewusster, separater Klick bleiben. +# Backend bewusst Kea statt isc-dhcp-server (seit 2022 EOL) — Kea bildet +# "globaler Wert, pro Client überschreibbar" nativ über Reservation-Options +# ab. Installation & Dienststeuerung sind eigene, einzeln bestätigte +# Aktionen statt an "Konfiguration speichern" gekoppelt, da ein +# unerwarteter DHCP-Server ein Netzsegment stören kann. # --------------------------------------------------------------------------- DHCP_PACKAGE = "kea-dhcp4-server" @@ -1528,15 +1534,26 @@ DHCP_SERVICE = "kea-dhcp4-server" DHCP_DEFAULTS = { "dhcp_interface": "eth0", - "dhcp_range_start": "192.168.1.100", - "dhcp_range_end": "192.168.1.200", - "dhcp_dns": "192.168.1.1", + # Bewusst KEINE vorausgefüllten Range-/DNS-Defaults, die wie eine echte + # Konfiguration aussehen könnten, aber zufällig nicht zum echten Netz + # passen — leer + Platzhaltertext im Formular ist ehrlicher, als einen + # Dienst unbemerkt mit falschen Werten starten zu lassen. + "dhcp_range_start": "", + "dhcp_range_end": "", + "dhcp_dns": "", + # Optionaler Override — leer bedeutet "automatisch erkanntes Gateway + # dieses Hosts verwenden" (siehe _render_kea_config). + "dhcp_gateway": "", "dhcp_domain": "local", "dhcp_lease_default": "600", "dhcp_lease_max": "7200", "dhcp_output_path": "/etc/kea/kea-dhcp4.conf", } + +def _dhcp_config_is_complete(cfg): + return bool(cfg.get("dhcp_range_start") and cfg.get("dhcp_range_end")) + # Auswahl an Kea-Datentypen für eigene Options — bewusst eine kompatible # Teilmenge statt der vollen Liste, orientiert an den in der Praxis # gebrauchten Fällen (Text, IP, Zahl, Wahrheitswert, Rohdaten/Hex). @@ -1718,7 +1735,10 @@ def _render_kea_config(cfg, net_info, reservations, option_defs, option_values): Options für genau diesen Client, ohne dass wir das selbst zusammenrechnen müssen.""" dns_servers = ", ".join(s.strip() for s in cfg["dhcp_dns"].split(",") if s.strip()) - router = net_info.get("gateway") or (cfg["dhcp_dns"].split(",")[0].strip() if cfg["dhcp_dns"] else "") + # Gateway: expliziter Override hat Vorrang, sonst automatisch erkanntes + # Gateway dieses Hosts — für den Fall, dass Clients einen anderen Router + # bekommen sollen als den, über den dieser Host selbst geht. + router = cfg.get("dhcp_gateway", "").strip() or net_info.get("gateway") or "" option_def_list = [ {"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"} @@ -1765,7 +1785,11 @@ def _render_kea_config(cfg, net_info, reservations, option_defs, option_values): { "id": 1, "subnet": f"{net_info['network'].network_address}/{net_info['network'].prefixlen}", - "pools": [{"pool": f"{cfg['dhcp_range_start']} - {cfg['dhcp_range_end']}"}], + # Kein Pool ohne eingetragene Range — ein leerer/falscher + # Pool-String wäre eine Kea-Konfiguration, die entweder + # gar nicht startet oder (schlimmer) mit einer geratenen + # Range startet, die nicht zum echten Netz passt. + **({"pools": [{"pool": f"{cfg['dhcp_range_start']} - {cfg['dhcp_range_end']}"}]} if _dhcp_config_is_complete(cfg) else {}), "reservations": kea_reservations, } ] if net_info.get("ok") else [], @@ -1882,6 +1906,10 @@ def settings_dhcp(): elif "write_dhcp_file" in request.form: cfg = _load_dhcp_settings() + if not _dhcp_config_is_complete(cfg): + flash("Range Start/Ende müssen eingetragen sein, bevor eine Konfiguration geschrieben wird.", "danger") + conn.close() + return redirect(url_for("settings_dhcp")) net_info = _detect_interface_network(cfg["dhcp_interface"]) if not net_info["ok"]: flash(f"Netzwerk-Erkennung für „{cfg['dhcp_interface']}“ fehlgeschlagen: {net_info['error']} — nichts geschrieben.", "danger") @@ -1927,7 +1955,9 @@ def settings_dhcp(): elif "dhcp_service_action" in request.form: action = request.form.get("dhcp_service_action") - if action == "enable_restart": + if action == "enable_restart" and not _dhcp_config_is_complete(_load_dhcp_settings()): + flash("Range Start/Ende müssen eingetragen und die Konfiguration geschrieben sein, bevor der Dienst gestartet wird.", "danger") + elif action == "enable_restart": ok_enable, _ = _dhcp_run_privileged(["systemctl", "enable", DHCP_SERVICE], timeout=15) ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30) log_action("settings.dhcp_service", DHCP_SERVICE, "enable+restart " + ("ok" if (ok_enable and ok_restart) else "fehlgeschlagen")) diff --git a/srv/poe_manager/static/js/app.js b/srv/poe_manager/static/js/app.js index dfb1128..e7df164 100644 --- a/srv/poe_manager/static/js/app.js +++ b/srv/poe_manager/static/js/app.js @@ -366,6 +366,27 @@ }); } + /* ---------------- Quelltext-Hürde ---------------- + Blockiert Rechtsklick-Menü und die üblichen DevTools-/Quelltext- + Shortcuts. WICHTIG: das ist eine Hürde für gelegentliches Kopieren, + KEIN echter Schutz — jeder Browser liefert HTML/CSS/JS immer + vollständig aus, sonst könnte er die Seite nicht darstellen. Wer + wirklich an den Quelltext will, umgeht das in Sekunden (URL-Leiste, + externer HTTP-Client, DevTools per Menü statt Shortcut). Echte + Sicherheit kommt ausschließlich von den serverseitigen Rechteprüfungen + in app.py, nicht davon, das Nachsehen im Browser zu erschweren. */ + function initSourceProtection() { + document.addEventListener("contextmenu", (e) => e.preventDefault()); + document.addEventListener("keydown", (e) => { + const key = e.key.toUpperCase(); + const blocked = + key === "F12" || + (e.ctrlKey && e.shiftKey && ["I", "J", "C"].includes(key)) || + (e.ctrlKey && key === "U"); + if (blocked) e.preventDefault(); + }); + } + /* ---------------- Init ---------------- */ document.addEventListener("DOMContentLoaded", function () { @@ -378,6 +399,7 @@ initCheckTimer(); initRunCheckNow(); initSortableTables(); + initSourceProtection(); document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); }); diff --git a/srv/poe_manager/templates/account.html b/srv/poe_manager/templates/account.html index 81824f5..94c064c 100644 --- a/srv/poe_manager/templates/account.html +++ b/srv/poe_manager/templates/account.html @@ -67,7 +67,7 @@