Hostname-Einstellung, DHCP-Range-Pflicht, Topbar-Ampel, Text-Straffung, Quelltext-Huerde

Hostname (Systemeinstellungen, neben Pruefintervall):
- hostnamectl set-hostname ueber die App, Validierung (RFC-1123-artiges
  Label), kein Revert-Timer noetig (kappt die Erreichbarkeit nicht wie
  eine IP-Aenderung). Live getestet inkl. Validierung und Revert.

DHCP: Range ist jetzt Pflicht, kein geratener Default mehr:
- dhcp_range_start/end/dns starten leer (Platzhaltertext statt fake-
  echt aussehendem Default) - ein Zufalls-Range haette den Dienst sonst
  unbemerkt mit einer zum echten Netz nicht passenden Konfiguration
  starten lassen koennen.
- write_dhcp_file und dhcp_service_action=enable_restart verweigern sich
  ohne eingetragene Range; _render_kea_config laesst "pools" ohne Range
  komplett weg statt einen kaputten Pool-String zu erzeugen.
- Neues, separates dhcp_gateway-Feld (optional) fuer einen vom
  automatisch erkannten Gateway abweichenden Router fuer die Clients.

Topbar-Ampel fuer den Kea-Dienst:
- Gruener/roter Punkt neben dem Pruefintervall-Timer, nur sichtbar mit
  settings_dhcp.view (ein einzelner, kurzer systemctl-Aufruf pro Request,
  nicht die volle Status-Erkennung). Live verifiziert (rot wenn gestoppt,
  gruen wenn gestartet).

Reservierungstabelle zeigt jetzt pro Client, welche eigene DHCP-Option
greift (global oder Client-Override, mit Wert im Tooltip).

Text-Straffung: die laengsten Hint-Texte und Code-Kommentare in
Templates/app.py gekuerzt (u.a. groups.html, settings_dhcp.html,
account.html, devices/switches/users/credentials.html, zwei grosse
Migrations-/DHCP-Kommentarbloecke in app.py) - Kernaussagen erhalten,
Redundanz entfernt.

Quelltext-Huerde (KEINE echte Sicherheit, nur Abschreckung): Rechtsklick
und DevTools-/Quelltext-Shortcuts per JS blockiert. Klar dokumentiert
in Kommentar + README, dass der Browser HTML/CSS/JS immer vollstaendig
ausliefert und das in Sekunden umgehbar ist - echte Absicherung bleiben
ausschliesslich die serverseitigen Rechteprüfungen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 11:28:29 +02:00
co-authored by Claude Sonnet 5
parent 82edf7628b
commit 6803929140
13 changed files with 191 additions and 81 deletions
+81 -51
View File
@@ -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"))