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:
2026-08-10 21:10:05 +02:00
co-authored by Claude Sonnet 5
parent 06db4c1382
commit 9eb9cfdda6
5 changed files with 388 additions and 1 deletions
+200 -1
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, 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():
+14
View File
@@ -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; }
+1
View File
@@ -23,6 +23,7 @@
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
"transfer": '<path d="M17 3l4 4-4 4"/><path d="M3 7h18"/><path d="M7 21l-4-4 4-4"/><path d="M21 17H3"/>',
"network": '<rect x="9" y="2" width="6" height="6" rx="1.2"/><rect x="2" y="16" width="6" height="6" rx="1.2"/><rect x="16" y="16" width="6" height="6" rx="1.2"/><path d="M12 8v4M12 12H5v4M12 12h7v4"/>',
} %}
<div class="app-shell">
@@ -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 %}<div class="topbar-sub">Reservierungen aus den Client-Stammdaten generieren</div>{% endblock %}
{% block content %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">DHCP-Server-Status</h2>
<div class="hint">Rein lesende Erkennung — die App installiert oder startet nie selbst einen DHCP-Dienst.</div>
</div>
</div>
{% if status.installed %}
<div class="flex gap-2" style="align-items:center; margin-bottom:10px;">
<span class="pill online">Installiert</span>
{% if status.active is sameas true %}
<span class="pill online">Dienst aktiv</span>
{% elif status.active is sameas false %}
<span class="pill offline">Dienst inaktiv</span>
{% else %}
<span class="pill unknown">Dienst-Status unbekannt</span>
{% endif %}
</div>
{% if status.version %}<div class="text-faint mono" style="font-size:12px;">{{ status.version }}</div>{% endif %}
{% if status.active is not sameas true %}
<p class="text-faint" style="font-size:11.5px; margin-top:10px;">
Dienst manuell starten: <code>sudo systemctl enable --now isc-dhcp-server</code>
</p>
{% endif %}
{% else %}
<span class="pill disabled">Nicht installiert</span>
<p class="text-faint" style="font-size:11.5px; margin-top:10px;">
Kein <code>dhcpd</code> auf diesem Host gefunden. Installation bewusst manuell, außerhalb der App:<br>
<code>sudo apt-get install isc-dhcp-server</code>
</p>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Netzwerk-Konfiguration</h2>
<div class="hint">Grundlage für die generierte Subnet- und Reservierungs-Deklaration.</div>
</div>
</div>
{% if can_edit %}
<form method="post">
<input type="hidden" name="save_dhcp_config" value="1">
<div class="field"><label>Interface</label>
<input type="text" name="dhcp_interface" value="{{ cfg.dhcp_interface }}">
</div>
<div class="field"><label>Subnet</label>
<input type="text" name="dhcp_subnet" value="{{ cfg.dhcp_subnet }}">
</div>
<div class="field"><label>Netzmaske</label>
<input type="text" name="dhcp_netmask" value="{{ cfg.dhcp_netmask }}">
</div>
<div class="field"><label>Range Start</label>
<input type="text" name="dhcp_range_start" value="{{ cfg.dhcp_range_start }}">
</div>
<div class="field"><label>Range Ende</label>
<input type="text" name="dhcp_range_end" value="{{ cfg.dhcp_range_end }}">
</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.</div>
</div>
<div class="field"><label>Domain</label>
<input type="text" name="dhcp_domain" value="{{ cfg.dhcp_domain }}">
</div>
<div class="field"><label>Lease-Zeit Standard (Sek.)</label>
<input type="number" name="dhcp_lease_default" value="{{ cfg.dhcp_lease_default }}">
</div>
<div class="field"><label>Lease-Zeit Maximum (Sek.)</label>
<input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}">
</div>
<div class="field"><label>Ausgabepfad (Reservierungsdatei)</label>
<input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono">
<div class="field-hint">Separate Include-Datei — nicht die aktive dhcpd.conf. Per <code>include "{{ cfg.dhcp_output_path }}";</code> einmalig manuell einbinden.</div>
</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>
Konfiguration speichern
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „DHCP ändern“.</p>
{% 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;">Reservierungen aus den Clients</h2>
<div class="hint">
{{ 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 %}
</div>
</div>
{% if can_edit %}
<form method="post" data-confirm="Reservierungsdatei nach „{{ cfg.dhcp_output_path }}“ schreiben? Der DHCP-Dienst übernimmt die Änderung erst nach einem manuellen Reload.">
<input type="hidden" name="write_dhcp_file" value="1">
<button type="submit" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
In Datei schreiben
</button>
</form>
{% endif %}
</div>
{% if reservations %}
<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></tr></thead>
<tbody>
{% for r in reservations %}
<tr>
<td class="mono">{{ r.hostname }}</td>
<td class="mono">{{ r.mac }}</td>
<td class="mono">{{ r.ip }}</td>
<td>{{ r.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px;">Keine aktiven Clients mit MAC + IP vorhanden.</p>
{% endif %}
<div class="permission-group-title">Generierte Konfiguration (Vorschau)</div>
<pre class="code-preview">{{ preview }}</pre>
</div>
</div>
{% endblock %}