diff --git a/README.md b/README.md index c326dd9..770ab58 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,37 @@ sich per SSH mit einem Switch verbindet: dem Web-Terminal (Verbindungstest), `expect`-Aufrufen in `poe.sh` selbst (`ssh -p ...`) für automatische wie manuelle PoE-Neustarts. +## DHCP-Reservierungen (Systemeinstellungen → DHCP) + +Erzeugt aus den vorhandenen Client-Stammdaten (MAC + IP + Name) automatisch +feste DHCP-Reservierungen, ohne die Daten ein zweites Mal pflegen zu müssen. +Eigenes Rechtepaar `settings_dhcp.view`/`settings_dhcp.edit`, gespiegelt in +der Navbar unter Einstellungen. + +- **Status-Erkennung** (rein lesend): prüft per `shutil.which("dhcpd")` und + `systemctl is-active isc-dhcp-server`, ob ein ISC-DHCP-Server installiert + bzw. aktiv ist. Die App installiert und startet **nie selbst** einen + DHCP-Dienst — ist keiner gefunden, zeigt die Seite lediglich den passenden + manuellen Befehl (`sudo apt-get install isc-dhcp-server`) an. Grund: ein + unerwartet aktiver oder fehlkonfigurierter DHCP-Server kann ein ganzes + Netzwerksegment stören, das soll ausschließlich eine bewusste, + eigenständige Admin-Entscheidung außerhalb der App bleiben. +- **Konfiguration**: Interface, Subnet, Netzmaske, Range, DNS-Server, + Domain, Lease-Zeiten sowie der Ausgabepfad der generierten Datei — als + `dhcp_*`-Schlüssel in der bestehenden `settings`-Tabelle gespeichert. +- **Reservierungen**: für jedes aktive Gerät mit gültiger MAC + IP wird ein + `host { hardware ethernet ...; fixed-address ...; }`-Block erzeugt; der + Hostname wird aus dem Gerätenamen abgeleitet (nur `a-z0-9-`, Kollisionen + bekommen automatisch ein `-2`/`-3`-Suffix). Geräte ohne MAC/IP werden mit + Hinweis übersprungen statt eine kaputte Reservierung zu erzeugen. +- **Schreiben**: „In Datei schreiben“ (nur mit `settings_dhcp.edit`) + schreibt die generierte Subnet-/Reservierungs-Deklaration in eine + **separate Include-Datei** (Standard-Vorschlag + `/etc/dhcp/dhcpd.conf.d/aruba-poe-reservations.conf`) — **nicht** in die + eigentliche `dhcpd.conf`. Einmalig manuell per `include "";` + einbinden. Die App reloadet/restartet den DHCP-Dienst dabei bewusst + **nicht automatisch** — dieselbe Begründung wie bei der Status-Erkennung. + ## Änderungslog Jede Anlage, Bearbeitung, Löschung sowie jedes Aktivieren/Deaktivieren von diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index d6c1980..5114f2f 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, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback +import base64, shutil, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback import paramiko @@ -218,6 +218,12 @@ PERMISSIONS = { # L = Export (nur lesend), Ä = Import (verändert Bestand). "rows": {"view": "settings_importexport.view", "edit": "settings_importexport.edit"}, }, + "settings_dhcp": { + "label": "DHCP", + # L = Konfiguration/Status/Vorschau ansehen, Ä = Konfiguration + # speichern und die generierte Reservierungsdatei schreiben. + "rows": {"view": "settings_dhcp.view", "edit": "settings_dhcp.edit"}, + }, }, }, } @@ -242,6 +248,8 @@ PERMISSION_LABELS = { "groups.edit": "Gruppen ändern (inkl. Löschen)", "settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern", "settings_importexport.view": "Daten exportieren", "settings_importexport.edit": "Daten importieren", + "settings_dhcp.view": "DHCP-Status und -Konfiguration lesen", + "settings_dhcp.edit": "DHCP-Konfiguration speichern und Reservierungsdatei schreiben", } # Aus der verschachtelten Struktur abgeleitet: alle vergebbaren Keys, sowie @@ -286,6 +294,7 @@ NAV_ITEMS = [ {"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"}, {"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"}, {"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"}, + {"key": "settings_dhcp", "label": "DHCP", "icon": "network", "endpoint": "settings_dhcp"}, ]}, {"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [ {"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"}, @@ -313,6 +322,8 @@ def _nav_key_visible(key, user): return user.can_view_settings_system if key == "settings_importexport": return user.can_view_settings_importexport + if key == "settings_dhcp": + return user.can_view_settings_dhcp if key == "logs_live": return user.can_view_live_log if key == "logs_activity": @@ -415,6 +426,10 @@ class User(UserMixin): def can_view_settings_importexport(self): return self.has_permission("settings_importexport.view") + @property + def can_view_settings_dhcp(self): + return self.has_permission("settings_dhcp.view") + @property def full_name(self): parts = [p for p in (self.first_name, self.last_name) if p] @@ -1168,6 +1183,190 @@ def settings_import_export(): return render_template("settings_import_export.html") +# --------------------------------------------------------------------------- +# DHCP-Reservierungen +# +# Bewusste Beschränkung: die App erkennt nur, ob ein DHCP-Server installiert +# ist und generiert eine reine Reservierungs-Include-Datei aus den +# Client-Daten — sie installiert NIE selbst ein Paket und startet/reloadet +# NIE den DHCP-Dienst. Ein fehlkonfigurierter/unerwartet aktiver DHCP-Server +# kann ein ganzes Netz stören; Installation und Dienst-Reload bleiben daher +# bewusst manuelle, außerhalb der App liegende Admin-Schritte (Anleitung +# dazu wird auf der Seite angezeigt). +# --------------------------------------------------------------------------- + +DHCP_DEFAULTS = { + "dhcp_interface": "eth0", + "dhcp_subnet": "192.168.1.0", + "dhcp_netmask": "255.255.255.0", + "dhcp_range_start": "192.168.1.100", + "dhcp_range_end": "192.168.1.200", + "dhcp_dns": "192.168.1.1", + "dhcp_domain": "local", + "dhcp_lease_default": "600", + "dhcp_lease_max": "7200", + "dhcp_output_path": "/etc/dhcp/dhcpd.conf.d/aruba-poe-reservations.conf", +} + + +def _load_dhcp_settings(): + return {key: get_setting(key, default) for key, default in DHCP_DEFAULTS.items()} + + +def _detect_dhcp_status(): + """Rein lesende Erkennung, ob ein ISC-DHCP-Server auf diesem Host + installiert ist und läuft — führt selbst NIE eine Installation, einen + Start oder einen Reload aus.""" + dhcpd_path = shutil.which("dhcpd") + installed = dhcpd_path is not None + active = None + version = None + if installed: + try: + version_out = subprocess.run( + ["dhcpd", "--version"], capture_output=True, text=True, timeout=5 + ) + version = (version_out.stdout or version_out.stderr or "").strip().splitlines()[0] if (version_out.stdout or version_out.stderr) else None + except Exception: + version = None + try: + status = subprocess.run( + ["systemctl", "is-active", "isc-dhcp-server"], + capture_output=True, text=True, timeout=5, + ) + active = status.stdout.strip() == "active" + except Exception: + active = None + return {"installed": installed, "path": dhcpd_path, "active": active, "version": version} + + +def _dhcp_safe_hostname(name, mac): + """Wandelt einen Client-Namen in einen für DHCP-Host-Deklarationen und + option host-name gültigen Bezeichner um (nur a-z0-9-, keine + führenden/abschließenden/doppelten Bindestriche). Fällt bei leerem + Ergebnis auf die letzten 4 Byte der MAC zurück, damit nie ein leerer + oder doppelter Bezeichner entsteht.""" + slug = re.sub(r"[^a-z0-9-]+", "-", (name or "").strip().lower()).strip("-") + slug = re.sub(r"-{2,}", "-", slug) + if slug: + return slug + return "geraet-" + re.sub(r"[^0-9a-f]", "", mac.lower())[-8:] + + +def _dhcp_reservation_candidates(conn): + """Aktive Geräte mit gültiger MAC + IP, aus denen sich eine feste + DHCP-Reservierung erzeugen lässt. Geräte ohne rpi_ip/mac werden + übersprungen statt eine kaputte Reservierung zu erzeugen.""" + rows = conn.execute( + "SELECT mac, rpi_ip, name FROM devices WHERE is_active=1 AND mac IS NOT NULL AND rpi_ip IS NOT NULL ORDER BY name ASC" + ).fetchall() + seen_hostnames = set() + reservations = [] + for row in rows: + hostname = _dhcp_safe_hostname(row["name"], row["mac"]) + # Kollisionen bei generierten Namen (z.B. zwei Geräte mit gleichem + # Namen) auflösen, statt zwei Host-Blöcke mit demselben Bezeichner + # zu erzeugen (das würde dhcpd -t als Fehler ablehnen). + base_hostname, suffix = hostname, 2 + while hostname in seen_hostnames: + hostname = f"{base_hostname}-{suffix}" + suffix += 1 + seen_hostnames.add(hostname) + reservations.append({"mac": row["mac"], "ip": row["rpi_ip"], "hostname": hostname, "name": row["name"]}) + return reservations + + +def _render_dhcp_config(cfg, reservations): + lines = [ + "# Automatisch generiert vom PoE Manager — Änderungen werden beim", + "# nächsten Schreiben aus den Geräte-Stammdaten überschrieben.", + "# Diese Datei enthält NUR die Subnet-/Reservierungs-Deklaration —", + "# per \"include\" aus der eigentlichen dhcpd.conf einbinden.", + "", + f"subnet {cfg['dhcp_subnet']} netmask {cfg['dhcp_netmask']} {{", + f" option routers {cfg['dhcp_dns'].split(',')[0].strip() if cfg['dhcp_dns'] else cfg['dhcp_subnet']};", + f" option domain-name-servers {cfg['dhcp_dns']};", + f" option domain-name \"{cfg['dhcp_domain']}\";", + f" range {cfg['dhcp_range_start']} {cfg['dhcp_range_end']};", + f" default-lease-time {cfg['dhcp_lease_default']};", + f" max-lease-time {cfg['dhcp_lease_max']};", + "}", + "", + ] + for r in reservations: + lines += [ + f"host {r['hostname']} {{", + f" hardware ethernet {r['mac']};", + f" fixed-address {r['ip']};", + f" option host-name \"{r['hostname']}\";", + "}", + ] + return "\n".join(lines) + "\n" + + +@app.route("/settings/dhcp", methods=["GET", "POST"]) +@login_required +def settings_dhcp(): + if request.method == "GET" and not current_user.can_view_settings_dhcp: + flash("Keine Berechtigung, die DHCP-Einstellungen anzusehen.", "danger") + return redirect(url_for("index")) + + conn = get_db_connection() + + if request.method == "POST": + if not current_user.has_permission("settings_dhcp.edit"): + flash("Keine Berechtigung, die DHCP-Konfiguration zu ändern.", "danger") + conn.close() + return redirect(url_for("settings_dhcp")) + + if "save_dhcp_config" in request.form: + for key in DHCP_DEFAULTS: + value = request.form.get(key, "").strip() + if value: + set_setting(key, value) + log_action("settings.update", "DHCP-Konfiguration") + flash("DHCP-Konfiguration gespeichert.", "success") + + elif "write_dhcp_file" in request.form: + cfg = _load_dhcp_settings() + reservations = _dhcp_reservation_candidates(conn) + content = _render_dhcp_config(cfg, reservations) + output_path = cfg["dhcp_output_path"] + try: + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + f.write(content) + log_action("settings.dhcp_write", output_path, f"{len(reservations)} Reservierungen") + flash( + f"{len(reservations)} Reservierungen nach {output_path} geschrieben. " + f"Der DHCP-Dienst übernimmt die Änderung erst nach einem manuellen " + f"„sudo systemctl reload isc-dhcp-server“ — das führt die App bewusst nicht automatisch aus.", + "success", + ) + except OSError as e: + flash(f"Konnte Datei nicht schreiben: {e}", "danger") + + conn.close() + return redirect(url_for("settings_dhcp")) + + cfg = _load_dhcp_settings() + reservations = _dhcp_reservation_candidates(conn) + skipped_count = conn.execute( + "SELECT COUNT(*) AS n FROM devices WHERE is_active=1 AND (mac IS NULL OR rpi_ip IS NULL)" + ).fetchone()["n"] + conn.close() + status = _detect_dhcp_status() + preview = _render_dhcp_config(cfg, reservations) + return render_template( + "settings_dhcp.html", + cfg=cfg, + status=status, + reservations=reservations, + skipped_count=skipped_count, + preview=preview, + ) + + @app.route("/settings/nav-order", methods=["POST"]) @login_required def save_nav_order(): diff --git a/srv/poe_manager/static/css/style.css b/srv/poe_manager/static/css/style.css index bd65f36..7d8e10a 100644 --- a/srv/poe_manager/static/css/style.css +++ b/srv/poe_manager/static/css/style.css @@ -1024,6 +1024,20 @@ select { word-break: break-word; color: #c7ccd6; } +.code-preview { + background: #0a0c10; + border: 1px solid var(--border-soft); + border-radius: 8px; + padding: 14px 18px; + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.6; + color: #c7ccd6; + max-height: 360px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} .log-line { display: block; } .log-line.online { color: #4ade80; } .log-line.offline { color: #f87171; } diff --git a/srv/poe_manager/templates/base.html b/srv/poe_manager/templates/base.html index 4c3f0fa..bfa6bcd 100644 --- a/srv/poe_manager/templates/base.html +++ b/srv/poe_manager/templates/base.html @@ -23,6 +23,7 @@ "logout": '', "gear": '', "transfer": '', + "network": '', } %}
diff --git a/srv/poe_manager/templates/settings_dhcp.html b/srv/poe_manager/templates/settings_dhcp.html new file mode 100644 index 0000000..baa3e93 --- /dev/null +++ b/srv/poe_manager/templates/settings_dhcp.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} +{% set active_page = "settings_dhcp" %} +{% set can_edit = current_user.has_permission('settings_dhcp.edit') %} +{% block page_title %}DHCP{% endblock %} +{% block page_sub %}
Reservierungen aus den Client-Stammdaten generieren
{% endblock %} + +{% block content %} +
+ +
+
+
+

DHCP-Server-Status

+
Rein lesende Erkennung — die App installiert oder startet nie selbst einen DHCP-Dienst.
+
+
+ {% if status.installed %} +
+ Installiert + {% if status.active is sameas true %} + Dienst aktiv + {% elif status.active is sameas false %} + Dienst inaktiv + {% else %} + Dienst-Status unbekannt + {% endif %} +
+ {% if status.version %}
{{ status.version }}
{% endif %} + {% if status.active is not sameas true %} +

+ Dienst manuell starten: sudo systemctl enable --now isc-dhcp-server +

+ {% endif %} + {% else %} + Nicht installiert +

+ Kein dhcpd auf diesem Host gefunden. Installation bewusst manuell, außerhalb der App:
+ sudo apt-get install isc-dhcp-server +

+ {% endif %} +
+ +
+
+
+

Netzwerk-Konfiguration

+
Grundlage für die generierte Subnet- und Reservierungs-Deklaration.
+
+
+ {% if can_edit %} +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
Kommagetrennt, falls mehrere.
+
+
+ +
+
+ +
+
+ +
+
+ +
Separate Include-Datei — nicht die aktive dhcpd.conf. Per include "{{ cfg.dhcp_output_path }}"; einmalig manuell einbinden.
+
+ +
+ {% else %} +

Für Änderungen fehlt das Recht „DHCP ändern“.

+ {% endif %} +
+ +
+
+
+

Reservierungen aus den Clients

+
+ {{ reservations|length }} aktive{{ ' Clients' if reservations|length != 1 else 'r Client' }} mit gültiger MAC + IP. + {% if skipped_count %}{{ skipped_count }} aktive{{ ' Clients' if skipped_count != 1 else 'r Client' }} ohne MAC/IP übersprungen.{% endif %} +
+
+ {% if can_edit %} +
+ + +
+ {% endif %} +
+ + {% if reservations %} +
+
+ + + + {% for r in reservations %} + + + + + + + {% endfor %} + +
HostnameMACIP-AdresseClient-Name
{{ r.hostname }}{{ r.mac }}{{ r.ip }}{{ r.name }}
+
+
+ {% else %} +

Keine aktiven Clients mit MAC + IP vorhanden.

+ {% endif %} + +
Generierte Konfiguration (Vorschau)
+
{{ preview }}
+
+ +
+{% endblock %}