DHCP: Mehrfach-Subnetze, aktive Leases-Anzeige, Reboot-Persistenz

1. Mehrere gleichzeitige DHCP-Subnetze statt einer einzelnen globalen Range
   -- ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz haben,
   fuer die alle DHCP angeboten werden soll:
   - Neue Tabelle dhcp_subnets (interface, range_start/end, optionales
     gateway/dns je Zeile) statt der bisherigen dhcp_interface/_range_*/
     _gateway/_dns-Settings-Keys. Domain, Lease-Zeiten und Ausgabepfad
     bleiben global (Kea-weit gueltig).
   - Einmalige Migration: eine bestehende globale Einzel-Range wird beim
     ersten Start automatisch in eine erste Subnetz-Zeile ueberfuehrt,
     statt eine funktionierende Konfiguration beim Upgrade zu verlieren
     (live verifiziert: bestehende 192.168.80.220-230-Range korrekt
     uebernommen).
   - Neue Routen add/edit/delete_dhcp_subnet, jede mit derselben
     Sicherheitsregel wie zuvor: es muss eine physische IP im gewuenschten
     Bereich vorhanden sein (_dhcp_matching_network je Subnetz-Zeile),
     sonst wird die Aktion abgelehnt. _detect_interface_network wurde dafuer
     in eine neue _detect_interface_networks (alle IPv4-Adressen eines
     Interfaces, nicht nur die erste -- ein Interface kann mehrere IPs
     tragen) plus einen duennen Wrapper fuer die primaere Adresse aufgeteilt.
   - _render_kea_config generiert jetzt einen eigenen subnet4-Block je
     Subnetz (eigenes Gateway/DNS-option-data), _dhcp_reservation_candidates
     ordnet jedes Geraet anhand seiner IP dem richtigen Subnetz zu.
   - "In Datei schreiben" ueberspringt inzwischen ungueltig gewordene
     Subnetze einzeln (mit Warnung, welche) statt die ganze Aktion
     abzubrechen; "Aktivieren & (neu) starten" verweigert den Start, falls
     kein einziges gueltiges Subnetz mehr existiert.
   - Live auf der Test-VM verifiziert: Anlegen/Bearbeiten/Loeschen inkl.
     Ablehnung ungueltiger Ranges, generierte Kea-Config mit zwei
     subnet4-Bloecken korrekt (Interface, Subnet-CIDR, Pools je Zeile).

2. Aktive Leases direkt aus Kea sichtbar (settings_dhcp.html, neue Karte)
   -- macht Clients OHNE eigene Reservierung sichtbar, die sich einfach
   eine freie IP aus dem Pool genommen haben, statt nur die (unvollstaendige)
   Reservierungsliste zu zeigen. Liest die memfile-Lease-CSV direkt
   (kein Kea-Control-Agent noetig), reserved-Flag durch Abgleich mit den
   bekannten Reservierungs-MACs. Live gegen eine echte (leere)
   kea-leases4.csv verifiziert.

3. DHCP-Dienst uebersteht einen Host-Neustart jetzt korrekt im zuletzt
   bewusst gewaehlten Zustand: "Stoppen" deaktiviert den Dienst zusaetzlich
   (nicht nur systemctl stop), sonst wuerde systemd ihn nach einem Neustart
   automatisch wieder hochfahren, obwohl der Admin ihn bewusst abgeschaltet
   hat. Dieselbe Ergaenzung im automatischen Netzwerkaenderungs-Stop (siehe
   vorheriger Commit) -- sonst koennte ein Host-Neustart vor der
   Range-Pruefung den Dienst trotzdem mit einer ggf. falschen Konfiguration
   wieder starten.
This commit is contained in:
2026-08-11 14:42:27 +02:00
parent efda77f813
commit bf9b816fa2
2 changed files with 279 additions and 85 deletions
+203 -43
View File
@@ -34,7 +34,7 @@
<button type="submit" class="btn btn-secondary btn-sm">Aktivieren &amp; (neu) starten</button>
</form>
{% if status.active is sameas true %}
<form method="post" data-confirm="{{ dhcp_service }} stoppen? Der DHCP-Dienst vergibt danach keine Adressen mehr.">
<form method="post" data-confirm="{{ dhcp_service }} stoppen und deaktivieren? Startet nach einem Host-Neustart dann nicht automatisch wieder.">
<input type="hidden" name="dhcp_service_action" value="stop">
<button type="submit" class="btn btn-secondary btn-sm" style="color:var(--danger);">Stoppen</button>
</form>
@@ -58,50 +58,13 @@
<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">Subnet/Netzmaske/Gateway werden live vom gewählten Interface übernommen, nicht manuell gepflegt.</div>
<h2 style="font-size:16px;">Globale DHCP-Einstellungen</h2>
<div class="hint">Gelten für den gesamten Dienst, unabhängig von den Subnetzen unten.</div>
</div>
</div>
{% if net_info.ok %}
<div class="flex gap-2" style="align-items:center; margin-bottom:16px; flex-wrap:wrap;">
<span class="pill online">Erkannt</span>
<span class="mono" style="font-size:12.5px;">{{ net_info.ip }}/{{ net_info.prefix }} (Netz {{ net_info.network }})</span>
{% if net_info.gateway %}<span class="text-faint mono" style="font-size:12px;">Gateway {{ net_info.gateway }}</span>{% endif %}
</div>
{% else %}
<div class="flex gap-2" style="align-items:center; margin-bottom:16px;">
<span class="pill offline">Nicht erkannt</span>
<span class="text-faint" style="font-size:12px;">{{ net_info.error }}</span>
</div>
{% endif %}
{% if can_edit %}
<form method="post">
<input type="hidden" name="save_dhcp_config" value="1">
<div class="field"><label>Interface</label>
<select name="dhcp_interface">
{% for iface in interfaces %}
<option value="{{ iface }}" {% if iface == cfg.dhcp_interface %}selected{% endif %}>{{ iface }}</option>
{% endfor %}
</select>
<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 }}" 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 }}" 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>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 }}">
</div>
@@ -125,6 +88,71 @@
{% 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;">Subnetze</h2>
<div class="hint">
Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range,
optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.
</div>
</div>
{% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addSubnetModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Subnetz hinzufügen
</button>
{% endif %}
</div>
{% if subnets %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Interface</th><th>Range</th><th>Gateway</th><th>DNS</th><th>Erkanntes Netz</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for s in subnets %}
{% set st = subnet_status.get(s.id) %}
<tr>
<td class="mono">{{ s.interface }}</td>
<td class="mono">{{ s.range_start }} {{ s.range_end }}</td>
<td class="mono text-faint">{{ s.gateway or '(automatisch)' }}</td>
<td class="mono text-faint">{{ s.dns or '—' }}</td>
<td>
{% if st and st.ok %}
<span class="pill online">Erkannt</span>
<span class="mono text-faint" style="font-size:11.5px;">{{ st.network }}</span>
{% else %}
<span class="pill offline">Nicht erkannt</span>
{% if st %}<span class="text-faint" style="font-size:11px;">{{ st.error }}</span>{% endif %}
{% endif %}
</td>
<td>
{% if can_edit %}
<div class="flex gap-2">
<button type="button" class="icon-btn" title="Bearbeiten" data-open-modal="editSubnetModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4z"/></svg>
</button>
<form method="post" data-confirm="Subnetz {{ s.range_start }}{{ s.range_end }} löschen?">
<input type="hidden" name="delete_dhcp_subnet" value="{{ s.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Noch kein Subnetz konfiguriert — der Dienst kann so nicht gestartet werden.</p>
{% endif %}
</div>
<div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;">
<div>
@@ -184,14 +212,61 @@
{% 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;">Aktive Leases</h2>
<div class="hint">
Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung,
die sich einfach eine freie IP aus dem Pool genommen haben.
</div>
</div>
</div>
{% if leases is none %}
<p class="text-faint" style="font-size:12.5px;">
Keine Lease-Datei gefunden — der Dienst wurde vermutlich noch nie gestartet oder hat noch keine Adresse vergeben.
</p>
{% elif leases %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>IP-Adresse</th><th>MAC</th><th>Hostname</th><th>Läuft ab</th><th>Status</th></tr></thead>
<tbody>
{% for l in leases %}
<tr>
<td class="mono">{{ l.ip }}</td>
<td class="mono">{{ l.mac }}</td>
<td>{{ l.hostname or '—' }}</td>
<td class="text-faint mono" style="font-size:12px;">{{ l.expires_at or '—' }}</td>
<td>
{% if l.reserved %}
<span class="pill online">Reserviert</span>
{% else %}
<span class="pill unknown" title="Client hat sich diese IP selbst aus dem Pool geholt, ohne eigene Reservierung">
⚠ Ohne Reservierung
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Aktuell keine aktiven Leases vergeben.</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 im konfigurierten Subnet.
{{ reservations|length }} aktive{{ ' Clients' if reservations|length != 1 else 'r Client' }} mit gültiger MAC + IP in einem der konfigurierten Subnetze.
{% 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 ü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' }} aller konfigurierten Subnetze übersprungen.{% endif %}
</div>
</div>
{% if can_edit %}
@@ -209,7 +284,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>Optionen</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>Subnetz</th><th>Optionen</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for r in reservations %}
<tr>
@@ -217,6 +292,7 @@
<td class="mono">{{ r.mac }}</td>
<td class="mono">{{ r.ip }}</td>
<td>{{ r.name }}</td>
<td class="mono text-faint" style="font-size:12px;">{{ r.subnet_label }}</td>
<td style="font-size:12px;">
{% for d in option_defs %}
{% set override = option_values.get(d.id, {}).get(r.mac) %}
@@ -323,6 +399,90 @@
</form>
</div>
</div>
<div class="modal-overlay" id="addSubnetModal">
<div class="modal">
<form method="post">
<input type="hidden" name="add_dhcp_subnet" value="1">
<div class="modal-header">
<h3>Subnetz hinzufügen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Interface</label>
<select name="interface" required>
{% for iface in interfaces %}
<option value="{{ iface }}">{{ iface }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>Range Start</label>
<input type="text" name="range_start" required placeholder="z.B. 192.168.1.100">
</div>
<div class="field"><label>Range Ende</label>
<input type="text" name="range_end" required placeholder="z.B. 192.168.1.200">
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
</div>
<div class="field"><label>Gateway (optional)</label>
<input type="text" name="gateway" placeholder="Automatisch: erkanntes Gateway des Interfaces">
</div>
<div class="field"><label>DNS-Server (optional)</label>
<input type="text" name="dns" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
</div>
</div>
{% endif %}
<!-- Bearbeiten-Modals außerhalb der Karte/Tabelle (siehe Kommentar oben
bei den Reservierungen — dieselbe HTML-Validität-Begründung). -->
{% if can_edit %}
{% for s in subnets %}
<div class="modal-overlay" id="editSubnetModal{{ loop.index }}">
<div class="modal">
<form method="post">
<input type="hidden" name="edit_dhcp_subnet" value="{{ s.id }}">
<div class="modal-header">
<h3>Subnetz bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Interface</label>
<select name="interface" required>
{% for iface in interfaces %}
<option value="{{ iface }}" {% if iface == s.interface %}selected{% endif %}>{{ iface }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>Range Start</label>
<input type="text" name="range_start" value="{{ s.range_start }}" required>
</div>
<div class="field"><label>Range Ende</label>
<input type="text" name="range_end" value="{{ s.range_end }}" required>
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
</div>
<div class="field"><label>Gateway (optional)</label>
<input type="text" name="gateway" value="{{ s.gateway or '' }}" placeholder="Automatisch: erkanntes Gateway des Interfaces">
</div>
<div class="field"><label>DNS-Server (optional)</label>
<input type="text" name="dns" value="{{ s.dns or '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}