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
+21 -2
View File
@@ -263,6 +263,16 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client
schreibt die vollständige, generierte Kea-JSON-Konfiguration an den schreibt die vollständige, generierte Kea-JSON-Konfiguration an den
konfigurierten Ausgabepfad. Wirksam wird sie erst nach einem konfigurierten Ausgabepfad. Wirksam wird sie erst nach einem
Dienst-Neustart über den separaten Button. 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: Live gegen eine echte, frisch installierte Kea-Instanz verifiziert:
Installation, generierte Konfiguration besteht `kea-dhcp4 -t`, Dienststart 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 Eigene, klar getrennte Funktion von obigem DHCP-*Server*: hier geht es um
die Netzwerkkonfiguration des Hosts, auf dem der PoE Manager selbst läuft die Netzwerkkonfiguration des Hosts, auf dem der PoE Manager selbst läuft
(IP-Adresse, DNS-Server, DHCP-Client vs. statisch) — nicht um Adressen, die (IP-Adresse, DNS-Server, DHCP-Client vs. statisch, Hostname) — nicht um
er an andere Geräte vergibt. 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 - **Backend-Erkennung**: prüft, ob NetworkManager oder dhcpcd aktiv ist
(`systemctl is-active`). Ist keins von beiden aktiv, bleibt die Seite (`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, empfiehlt es sich, `poe_web.service` künftig unter einem eigenen,
unprivilegierten Systembenutzer laufen zu lassen (im Original wie auch hier unprivilegierten Systembenutzer laufen zu lassen (im Original wie auch hier
aktuell `root`, um Änderungen am produktiven Setup nicht zu vergrößern). 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 ## Projektstruktur
+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.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
from datetime import datetime 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 import paramiko
@@ -623,24 +623,12 @@ def _ensure_schema():
(cur.lastrowid, sw["hostname"]), (cur.lastrowid, sw["hostname"]),
) )
# Migration (Einführung des granularen, an die Navbar gespiegelten # Migration (granularer Rechtebaum): devices.toggle -> devices.edit,
# Rechtebaums): alte Permission-Keys auf die neuen übertragen, damit # switches.* -> gespiegelte credentials.*, sowie Backfill des neuen
# bestehende Gruppen durch das Update keinen Zugriff verlieren. # "Bereich sichtbar"-Rechts für Gruppen mit bereits gesetzten Kind-Rechten.
# - devices.toggle ist jetzt Teil von devices.edit (Ändern). # WICHTIG: nur EINMALIG (Guard über settings) — sonst würde das
# - Zugangsdaten hatten bisher keine eigenen Rechte, sondern hingen an # Bereichs-Recht bei jedem Neustart automatisch neu gesetzt und den
# switches.* — bestehende Gruppen bekommen die entsprechenden # Kill-Switch (siehe User.has_permission) wirkungslos machen.
# 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_key = "_migrated_permission_hierarchy_v1" _migration_key = "_migrated_permission_hierarchy_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key,)).fetchone(): if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key,)).fetchone():
toggle_groups = conn.execute( 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): def get_device_status(devices):
""" """
Ermittelt aus dem aktuellsten Logfile in einem Durchlauf je Gerät: 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 # System-Netzwerkeinstellungen (IP/DNS/DHCP DIESES Hosts — nicht der
# verwechseln mit dem Kea-DHCP-*Server* für Client-Geräte weiter unten). # Kea-DHCP-*Server* für Client-Geräte weiter unten). Eine falsche Änderung
# # kann die Erreichbarkeit kappen, daher: Backend-Erkennung statt Annahme
# Höchste Vorsicht geboten: eine falsche Änderung hier kann die Erreichbarkeit # (unbekannt -> rein lesend), Backup vor jeder Änderung, und ein
# dieses Hosts (SSH/Web-UI) komplett kappen. Deshalb: # Sicherheits-Timer (analog "netplan try") der ohne explizite Bestätigung
# - Erkennung des tatsächlich aktiven Netzwerk-Backends (NetworkManager # innerhalb von NETWORK_REVERT_SECONDS automatisch zurückrollt.
# 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".
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
NETWORK_REVERT_SECONDS = 45 NETWORK_REVERT_SECONDS = 45
@@ -1470,6 +1465,18 @@ def settings():
else: else:
flash(f"Anwenden fehlgeschlagen: {out}", "danger") 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: elif "confirm_network" in request.form:
token = request.form.get("confirm_network") token = request.form.get("confirm_network")
entry = _pending_network_revert.pop(token, None) 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_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 net_state = _read_network_state(net_interface, net_backend) if net_interface else None
pending_token = next(iter(_pending_network_revert), None) pending_token = next(iter(_pending_network_revert), None)
try:
current_hostname = socket.gethostname()
except Exception:
current_hostname = None
return render_template( return render_template(
"settings.html", "settings.html",
interval=interval, interval=interval,
current_hostname=current_hostname,
net_backend=net_backend, net_backend=net_backend,
net_interfaces=_list_network_interfaces(), net_interfaces=_list_network_interfaces(),
net_interface=net_interface, net_interface=net_interface,
@@ -1510,17 +1522,11 @@ def settings_import_export():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# DHCP-Reservierungen (Kea DHCP) # DHCP-Reservierungen (Kea DHCP)
# #
# Backend bewusst Kea (kea-dhcp4-server), nicht das ältere isc-dhcp-server: # Backend bewusst Kea statt isc-dhcp-server (seit 2022 EOL) — Kea bildet
# Kea wird von ISC selbst aktiv weiterentwickelt (isc-dhcp-server ist seit # "globaler Wert, pro Client überschreibbar" nativ über Reservation-Options
# 2022 EOL) und bildet "globaler Wert, pro Client überschreibbar" nativ ab # ab. Installation & Dienststeuerung sind eigene, einzeln bestätigte
# (Reservation-Options überschreiben in Kea automatisch gleichnamige # Aktionen statt an "Konfiguration speichern" gekoppelt, da ein
# globale/Subnet-Options) — genau das von den Custom-Options gefordert. # unerwarteter DHCP-Server ein Netzsegment stören kann.
#
# 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.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DHCP_PACKAGE = "kea-dhcp4-server" DHCP_PACKAGE = "kea-dhcp4-server"
@@ -1528,15 +1534,26 @@ DHCP_SERVICE = "kea-dhcp4-server"
DHCP_DEFAULTS = { DHCP_DEFAULTS = {
"dhcp_interface": "eth0", "dhcp_interface": "eth0",
"dhcp_range_start": "192.168.1.100", # Bewusst KEINE vorausgefüllten Range-/DNS-Defaults, die wie eine echte
"dhcp_range_end": "192.168.1.200", # Konfiguration aussehen könnten, aber zufällig nicht zum echten Netz
"dhcp_dns": "192.168.1.1", # 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_domain": "local",
"dhcp_lease_default": "600", "dhcp_lease_default": "600",
"dhcp_lease_max": "7200", "dhcp_lease_max": "7200",
"dhcp_output_path": "/etc/kea/kea-dhcp4.conf", "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 # Auswahl an Kea-Datentypen für eigene Options — bewusst eine kompatible
# Teilmenge statt der vollen Liste, orientiert an den in der Praxis # Teilmenge statt der vollen Liste, orientiert an den in der Praxis
# gebrauchten Fällen (Text, IP, Zahl, Wahrheitswert, Rohdaten/Hex). # 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 Options für genau diesen Client, ohne dass wir das selbst
zusammenrechnen müssen.""" zusammenrechnen müssen."""
dns_servers = ", ".join(s.strip() for s in cfg["dhcp_dns"].split(",") if s.strip()) 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 = [ option_def_list = [
{"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"} {"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, "id": 1,
"subnet": f"{net_info['network'].network_address}/{net_info['network'].prefixlen}", "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, "reservations": kea_reservations,
} }
] if net_info.get("ok") else [], ] if net_info.get("ok") else [],
@@ -1882,6 +1906,10 @@ def settings_dhcp():
elif "write_dhcp_file" in request.form: elif "write_dhcp_file" in request.form:
cfg = _load_dhcp_settings() 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"]) net_info = _detect_interface_network(cfg["dhcp_interface"])
if not net_info["ok"]: if not net_info["ok"]:
flash(f"Netzwerk-Erkennung für „{cfg['dhcp_interface']}“ fehlgeschlagen: {net_info['error']} — nichts geschrieben.", "danger") 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: elif "dhcp_service_action" in request.form:
action = request.form.get("dhcp_service_action") 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_enable, _ = _dhcp_run_privileged(["systemctl", "enable", DHCP_SERVICE], timeout=15)
ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30) 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")) log_action("settings.dhcp_service", DHCP_SERVICE, "enable+restart " + ("ok" if (ok_enable and ok_restart) else "fehlgeschlagen"))
+22
View File
@@ -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 ---------------- */ /* ---------------- Init ---------------- */
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
@@ -378,6 +399,7 @@
initCheckTimer(); initCheckTimer();
initRunCheckNow(); initRunCheckNow();
initSortableTables(); initSortableTables();
initSourceProtection();
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
}); });
+1 -1
View File
@@ -67,7 +67,7 @@
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Navbar-Reihenfolge</h2> <h2 style="font-size:16px;">Navbar-Reihenfolge</h2>
<div class="hint">Bestimmt, in welcher Reihenfolge die Menüpunkte inkl. Unterpunkte in der Sidebar erscheinen. Jeder Benutzer sieht davon nur, wofür er auch berechtigt ist.</div> <div class="hint">Reihenfolge der Sidebar-Menüpunkte inkl. Unterpunkte.</div>
</div> </div>
</div> </div>
<form method="post" action="{{ url_for('save_nav_order') }}" id="navOrderForm"> <form method="post" action="{{ url_for('save_nav_order') }}" id="navOrderForm">
+5
View File
@@ -123,6 +123,11 @@
</button> </button>
{% endif %} {% endif %}
</span> </span>
{% if dhcp_topbar_active is not none %}
<span class="timer-pill" title="DHCP-Dienst: {{ 'aktiv' if dhcp_topbar_active else 'inaktiv' }}">
<span class="dot" style="animation:none; background:{{ 'var(--success)' if dhcp_topbar_active else 'var(--danger)' }}; box-shadow:none;"></span>DHCP
</span>
{% endif %}
{% if not current_user.is_authenticated %} {% if not current_user.is_authenticated %}
<a href="{{ url_for('login') }}" class="btn btn-primary"> <a href="{{ url_for('login') }}" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/></svg>
+1 -4
View File
@@ -11,10 +11,7 @@
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 style="font-size:16px;">Zugangsdaten</h2> <h2 style="font-size:16px;">Zugangsdaten</h2>
<div class="hint"> <div class="hint">Können mehreren Switchen zugleich zugeordnet werden.</div>
Zugangsdaten können mehreren Switchen gleichzeitig zugeordnet werden. Beim Anlegen eines
Switches lassen sich bestehende Zugangsdaten auswählen oder direkt neue hinterlegen.
</div>
</div> </div>
{% if can_create %} {% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="addCredentialModal"> <button type="button" class="btn btn-primary" data-open-modal="addCredentialModal">
+1 -1
View File
@@ -14,7 +14,7 @@
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 style="font-size:16px;">Geräte</h2> <h2 style="font-size:16px;">Geräte</h2>
<div class="hint">PoE-Geräte mit IP, MAC-Adresse und zugeordnetem Switch-Port für die automatische Erreichbarkeitsprüfung.</div> <div class="hint">PoE-Geräte mit IP, MAC und zugeordnetem Switch-Port.</div>
</div> </div>
{% if can_create %} {% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="deviceModal" onclick="resetDeviceForm()"> <button type="button" class="btn btn-primary" data-open-modal="deviceModal" onclick="resetDeviceForm()">
+2 -6
View File
@@ -91,12 +91,8 @@
<div> <div>
<h2 style="font-size:16px;">Gruppen</h2> <h2 style="font-size:16px;">Gruppen</h2>
<div class="hint"> <div class="hint">
Über Gruppen lassen sich Rechte für jeden Bereich der App granular vergeben. Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich.
Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich. Auf „Rechte“ klicken, „Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.
um eine Gruppe aufzuklappen und die Berechtigungen im Detail zu sehen bzw. zu ändern.
„Admin“ und „Benutzer“ sind feste Systemgruppen und nicht veränderbar. Das Kästchen neben dem
Bereichsnamen (Geräte/Logs/Einstellungen) muss gesetzt sein, bevor die Rechte darunter greifen —
automatische Neustarts bei Ausfall bleiben von „D“ unberührt. Legende direkt bei den Rechten.
</div> </div>
</div> </div>
{% if current_user.has_permission('groups.create') %} {% if current_user.has_permission('groups.create') %}
+27 -1
View File
@@ -34,11 +34,37 @@
{% endif %} {% endif %}
</div> </div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Hostname</h2>
<div class="hint">Name dieses Hosts im Netzwerk.</div>
</div>
</div>
{% if current_user.has_permission('settings_system.edit') %}
<form method="post">
<div class="field">
<label for="hostname">Hostname</label>
<input type="text" name="hostname" id="hostname" value="{{ current_hostname or '' }}" pattern="[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?" required>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Hostname setzen
</button>
</form>
{% else %}
<div class="field">
<label>Hostname</label>
<input type="text" value="{{ current_hostname or '' }}" disabled>
</div>
{% endif %}
</div>
<div class="card card-pad" style="grid-column:1 / -1;"> <div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Netzwerkeinstellungen</h2> <h2 style="font-size:16px;">Netzwerkeinstellungen</h2>
<div class="hint">IP-Adresse, DNS-Server und DHCP/Statisch-Umschaltung dieses Hosts selbst (nicht zu verwechseln mit dem DHCP-<em>Server</em> für Clients unter „DHCP“).</div> <div class="hint">IP/DNS/DHCP-Umschaltung dieses Hosts (nicht der DHCP-<em>Server</em> unter „DHCP“).</div>
</div> </div>
</div> </div>
+26 -11
View File
@@ -11,7 +11,7 @@
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">DHCP-Server-Status</h2> <h2 style="font-size:16px;">DHCP-Server-Status</h2>
<div class="hint">Rein lesende Erkennung — Installation und Dienststeuerung laufen ausschließlich über die Buttons unten, nie automatisch.</div> <div class="hint">Installation/Dienststeuerung nur über die Buttons unten.</div>
</div> </div>
</div> </div>
{% if status.installed %} {% if status.installed %}
@@ -59,7 +59,7 @@
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Netzwerk-Konfiguration</h2> <h2 style="font-size:16px;">Netzwerk-Konfiguration</h2>
<div class="hint">Subnet, Netzmaske und Gateway werden immer live aus der System-Netzwerkkonfiguration des gewählten Interfaces übernommen nicht manuell gepflegt, damit sie nie aus dem Ruder laufen können.</div> <div class="hint">Subnet/Netzmaske/Gateway werden live vom gewählten Interface übernommen, nicht manuell gepflegt.</div>
</div> </div>
</div> </div>
@@ -88,15 +88,19 @@
<div class="field-hint">Subnet/Netzmaske/Gateway oben werden für das hier ausgewählte Interface erkannt.</div> <div class="field-hint">Subnet/Netzmaske/Gateway oben werden für das hier ausgewählte Interface erkannt.</div>
</div> </div>
<div class="field"><label>Range Start</label> <div class="field"><label>Range Start</label>
<input type="text" name="dhcp_range_start" value="{{ cfg.dhcp_range_start }}"> <input type="text" name="dhcp_range_start" value="{{ cfg.dhcp_range_start }}" placeholder="z.B. 192.168.1.100" required>
</div> </div>
<div class="field"><label>Range Ende</label> <div class="field"><label>Range Ende</label>
<input type="text" name="dhcp_range_end" value="{{ cfg.dhcp_range_end }}"> <input type="text" name="dhcp_range_end" value="{{ cfg.dhcp_range_end }}" placeholder="z.B. 192.168.1.200" required>
<div class="field-hint">Muss innerhalb des oben erkannten Netzes liegen.</div> <div class="field-hint">Pflichtfeld, muss im oben erkannten Netz liegen — ohne Range startet der Dienst nicht.</div>
</div> </div>
<div class="field"><label>DNS-Server</label> <div class="field"><label>Gateway (optional)</label>
<input type="text" name="dhcp_dns" value="{{ cfg.dhcp_dns }}"> <input type="text" name="dhcp_gateway" value="{{ cfg.dhcp_gateway }}" placeholder="Automatisch: {{ net_info.gateway or '—' }}">
<div class="field-hint">Kommagetrennt, falls mehrere. Router/Gateway kommt vom erkannten Netz oben; nur falls dort keins gefunden wird, wird ersatzweise der erste DNS-Eintrag verwendet.</div> <div class="field-hint">Nur bei abweichendem Router für die Clients nötig — leer lassen für das oben erkannte Gateway.</div>
</div>
<div class="field"><label>DNS-Server (optional)</label>
<input type="text" name="dhcp_dns" value="{{ cfg.dhcp_dns }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
</div> </div>
<div class="field"><label>Domain</label> <div class="field"><label>Domain</label>
<input type="text" name="dhcp_domain" value="{{ cfg.dhcp_domain }}"> <input type="text" name="dhcp_domain" value="{{ cfg.dhcp_domain }}">
@@ -125,7 +129,7 @@
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Eigene DHCP-Options</h2> <h2 style="font-size:16px;">Eigene DHCP-Options</h2>
<div class="hint">Zusätzliche/herstellerspezifische Options (z.B. Terminal-URL) — global setzbar, pro Client über „Optionen“ bei den Reservierungen unten überschreibbar.</div> <div class="hint">Herstellerspezifische Options, global oder pro Client überschreibbar.</div>
</div> </div>
{% if can_edit %} {% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal"> <button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
@@ -187,7 +191,7 @@
<div class="hint"> <div class="hint">
{{ reservations|length }} aktive{{ ' Clients' if reservations|length != 1 else 'r Client' }} mit gültiger MAC + IP im konfigurierten Subnet. {{ reservations|length }} aktive{{ ' Clients' if reservations|length != 1 else 'r Client' }} mit gültiger MAC + IP im konfigurierten Subnet.
{% if skipped_count %}{{ skipped_count }} aktive{{ ' Clients' if skipped_count != 1 else 'r Client' }} ohne MAC/IP übersprungen.{% endif %} {% if skipped_count %}{{ skipped_count }} aktive{{ ' Clients' if skipped_count != 1 else 'r Client' }} ohne MAC/IP übersprungen.{% endif %}
{% if out_of_subnet_count %}{{ out_of_subnet_count }} aktive{{ ' Clients außerhalb' if out_of_subnet_count != 1 else 'r Client außerhalb' }} des erkannten Netzes{% if net_info.network %} „{{ net_info.network }}“{% endif %} übersprungen — Kea lehnt Reservierungen außerhalb ihres Subnets ab.{% endif %} {% if out_of_subnet_count %}{{ out_of_subnet_count }} aktive{{ ' Clients außerhalb' if out_of_subnet_count != 1 else 'r Client außerhalb' }} des erkannten Netzes übersprungen.{% endif %}
</div> </div>
</div> </div>
{% if can_edit %} {% if can_edit %}
@@ -205,7 +209,7 @@
<div class="table-wrap" style="margin-bottom:18px;"> <div class="table-wrap" style="margin-bottom:18px;">
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<table class="data-table"> <table class="data-table">
<thead><tr><th>Hostname</th><th>MAC</th><th>IP-Adresse</th><th>Client-Name</th><th style="width:1%;">Aktionen</th></tr></thead> <thead><tr><th>Hostname</th><th>MAC</th><th>IP-Adresse</th><th>Client-Name</th><th>Optionen</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody> <tbody>
{% for r in reservations %} {% for r in reservations %}
<tr> <tr>
@@ -213,6 +217,17 @@
<td class="mono">{{ r.mac }}</td> <td class="mono">{{ r.mac }}</td>
<td class="mono">{{ r.ip }}</td> <td class="mono">{{ r.ip }}</td>
<td>{{ r.name }}</td> <td>{{ r.name }}</td>
<td style="font-size:12px;">
{% for d in option_defs %}
{% set override = option_values.get(d.id, {}).get(r.mac) %}
{% set global_val = option_values.get(d.id, {}).get('') %}
{% if override %}
<span class="pill user" title="{{ d.name }} = {{ override }}">{{ d.name }} (eigen)</span>
{% elif global_val %}
<span class="pill" style="background:var(--muted-dim); color:var(--text-faint);" title="{{ d.name }} = {{ global_val }}">{{ d.name }} (global)</span>
{% endif %}
{% endfor %}
</td>
<td> <td>
{% if option_defs and can_edit %} {% if option_defs and can_edit %}
<button class="icon-btn" title="DHCP-Optionen für diesen Client" data-open-modal="deviceOptionsModal{{ loop.index }}"> <button class="icon-btn" title="DHCP-Optionen für diesen Client" data-open-modal="deviceOptionsModal{{ loop.index }}">
@@ -10,7 +10,7 @@
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Export</h2> <h2 style="font-size:16px;">Export</h2>
<div class="hint">Geräte, Switche und Zugangsdaten als verschlüsseltes Bundle sichern — z.B. für einen Umzug auf eine neue Umgebung.</div> <div class="hint">Geräte, Switche und Zugangsdaten verschlüsselt sichern.</div>
</div> </div>
</div> </div>
<form method="post" action="{{ url_for('export_data') }}"> <form method="post" action="{{ url_for('export_data') }}">
@@ -30,7 +30,7 @@
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Import</h2> <h2 style="font-size:16px;">Import</h2>
<div class="hint">Ein zuvor exportiertes Bundle einlesen. Bestehende Einträge mit gleichem Namen/Hostname/MAC werden aktualisiert, neue werden angelegt.</div> <div class="hint">Exportiertes Bundle einlesen — vorhandene Einträge werden aktualisiert, neue angelegt.</div>
</div> </div>
</div> </div>
{% if current_user.has_permission('settings_importexport.edit') %} {% if current_user.has_permission('settings_importexport.edit') %}
+1 -1
View File
@@ -14,7 +14,7 @@
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 style="font-size:16px;">Switche</h2> <h2 style="font-size:16px;">Switche</h2>
<div class="hint">Aruba-Switche mit ihren Zugangsdaten, über die der automatische PoE-Neustart bei Ausfällen sowie der Verbindungstest laufen.</div> <div class="hint">Aruba-Switche mit ihren Zugangsdaten für PoE-Neustart und Verbindungstest.</div>
</div> </div>
{% if can_create %} {% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="addSwitchModal" onclick="resetCredentialChoice('add')"> <button type="button" class="btn btn-primary" data-open-modal="addSwitchModal" onclick="resetCredentialChoice('add')">
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 style="font-size:16px;">Benutzer</h2> <h2 style="font-size:16px;">Benutzer</h2>
<div class="hint">Benutzerkonten mit Zugriff auf den PoE Manager. Die Gruppe bestimmt, welche Verwaltungsrechte ein Benutzer hat.</div> <div class="hint">Die Gruppe bestimmt die Rechte eines Benutzers.</div>
</div> </div>
{% if current_user.has_permission('users.create') %} {% if current_user.has_permission('users.create') %}
<button type="button" class="btn btn-primary" data-open-modal="userModal" onclick="document.getElementById('userForm').reset();"> <button type="button" class="btn btn-primary" data-open-modal="userModal" onclick="document.getElementById('userForm').reset();">