Neu: DHCP-Reservierungen aus Client-Stammdaten (Systemeinstellungen -> DHCP)
- Neue Unterseite unter Einstellungen (eigenes Rechtepaar
settings_dhcp.view/settings_dhcp.edit, in PERMISSIONS/NAV_ITEMS/
_nav_key_visible integriert, Kill-Switch über settings_group.view
greift wie bei den anderen Einstellungen-Unterpunkten).
- Rein lesende Installations-/Status-Erkennung (shutil.which("dhcpd"),
systemctl is-active isc-dhcp-server) — die App installiert/startet nie
selbst einen DHCP-Dienst, sondern zeigt bei fehlender Installation den
passenden manuellen Befehl an.
- Konfigurierbare Netzwerkparameter (Interface/Subnet/Netzmaske/Range/
DNS/Domain/Lease-Zeiten/Ausgabepfad), gespeichert als dhcp_*-Schlüssel
in der bestehenden settings-Tabelle.
- Reservierungen werden aus aktiven Geräten mit gültiger MAC+IP generiert
(Hostname aus Gerätename abgeleitet, Kollisionen automatisch
durchnummeriert, Geräte ohne MAC/IP werden übersprungen).
- "In Datei schreiben" (nur mit settings_dhcp.edit) schreibt eine
separate Include-Datei statt der aktiven dhcpd.conf; kein automatischer
Dienst-Reload/-Restart durch die App.
- Live getestet: Status-Erkennung, Config speichern, Reservierungs-
Generierung inkl. Namenskollisionen, Datei-Schreiben in sicheren
Testpfad, View-only-Gating (Formular ausgeblendet, POST blockiert),
Kill-Switch über settings_group.view.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+200
-1
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user