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"))
+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 ---------------- */
document.addEventListener("DOMContentLoaded", function () {
@@ -378,6 +399,7 @@
initCheckTimer();
initRunCheckNow();
initSortableTables();
initSourceProtection();
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>
<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>
<form method="post" action="{{ url_for('save_nav_order') }}" id="navOrderForm">
+5
View File
@@ -123,6 +123,11 @@
</button>
{% endif %}
</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 %}
<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>
+1 -4
View File
@@ -11,10 +11,7 @@
<div class="section-head">
<div>
<h2 style="font-size:16px;">Zugangsdaten</h2>
<div class="hint">
Zugangsdaten können mehreren Switchen gleichzeitig zugeordnet werden. Beim Anlegen eines
Switches lassen sich bestehende Zugangsdaten auswählen oder direkt neue hinterlegen.
</div>
<div class="hint">Können mehreren Switchen zugleich zugeordnet werden.</div>
</div>
{% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="addCredentialModal">
+1 -1
View File
@@ -14,7 +14,7 @@
<div class="section-head">
<div>
<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>
{% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="deviceModal" onclick="resetDeviceForm()">
+2 -6
View File
@@ -91,12 +91,8 @@
<div>
<h2 style="font-size:16px;">Gruppen</h2>
<div class="hint">
Über Gruppen lassen sich Rechte für jeden Bereich der App granular vergeben.
Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich. Auf „Rechte“ klicken,
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.
Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich.
„Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.
</div>
</div>
{% if current_user.has_permission('groups.create') %}
+27 -1
View File
@@ -34,11 +34,37 @@
{% endif %}
</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="section-head" style="margin-bottom:16px;">
<div>
<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>
+26 -11
View File
@@ -11,7 +11,7 @@
<div class="section-head" style="margin-bottom:16px;">
<div>
<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>
{% if status.installed %}
@@ -59,7 +59,7 @@
<div class="section-head" style="margin-bottom:16px;">
<div>
<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>
@@ -88,15 +88,19 @@
<div class="field-hint">Subnet/Netzmaske/Gateway oben werden für das hier ausgewählte Interface erkannt.</div>
</div>
<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 class="field"><label>Range Ende</label>
<input type="text" name="dhcp_range_end" value="{{ cfg.dhcp_range_end }}">
<div class="field-hint">Muss innerhalb des oben erkannten Netzes liegen.</div>
<input type="text" name="dhcp_range_end" value="{{ cfg.dhcp_range_end }}" placeholder="z.B. 192.168.1.200" required>
<div class="field-hint">Pflichtfeld, muss im oben erkannten Netz liegen — ohne Range startet der Dienst nicht.</div>
</div>
<div class="field"><label>DNS-Server</label>
<input type="text" name="dhcp_dns" value="{{ cfg.dhcp_dns }}">
<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"><label>Gateway (optional)</label>
<input type="text" name="dhcp_gateway" value="{{ cfg.dhcp_gateway }}" placeholder="Automatisch: {{ net_info.gateway or '—' }}">
<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 class="field"><label>Domain</label>
<input type="text" name="dhcp_domain" value="{{ cfg.dhcp_domain }}">
@@ -125,7 +129,7 @@
<div class="section-head" style="margin-bottom:16px;">
<div>
<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>
{% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
@@ -187,7 +191,7 @@
<div class="hint">
{{ 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 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>
{% if can_edit %}
@@ -205,7 +209,7 @@
<div class="table-wrap" style="margin-bottom:18px;">
<div style="overflow-x:auto;">
<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>
{% for r in reservations %}
<tr>
@@ -213,6 +217,17 @@
<td class="mono">{{ r.mac }}</td>
<td class="mono">{{ r.ip }}</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>
{% if option_defs and can_edit %}
<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>
<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>
<form method="post" action="{{ url_for('export_data') }}">
@@ -30,7 +30,7 @@
<div class="section-head" style="margin-bottom:16px;">
<div>
<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>
{% if current_user.has_permission('settings_importexport.edit') %}
+1 -1
View File
@@ -14,7 +14,7 @@
<div class="section-head">
<div>
<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>
{% if can_create %}
<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>
<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>
{% if current_user.has_permission('users.create') %}
<button type="button" class="btn btn-primary" data-open-modal="userModal" onclick="document.getElementById('userForm').reset();">