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
+76 -42
View File
@@ -229,20 +229,42 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client
- **Installation & Dienststeuerung**: eigene, jeweils einzeln bestätigte
Buttons — „kea-dhcp4-server installieren“ (`apt-get install -y`),
„Aktivieren & (neu) starten“ (`systemctl enable` + `restart`) und
„Stoppen“. Bewusst **nicht** automatisch an „Konfiguration speichern“
oder „In Datei schreiben“ gekoppelt — ein unerwartet aktiver oder
fehlkonfigurierter DHCP-Server kann ein ganzes Netzsegment stören, jede
dieser Aktionen soll ein bewusster, separater Klick bleiben.
- **Netzwerk-Kopplung**: Subnet, Netzmaske und Gateway werden **nie manuell
„Stoppen“ (`systemctl stop` **+ `disable`**, damit ein bewusst gestoppter
Dienst nach einem Host-Neustart nicht trotzdem automatisch wieder
hochfährt — ein Host-Neustart übernimmt so genau den zuletzt bewusst
gewählten Zustand, an oder aus). Bewusst **nicht** automatisch an
„Konfiguration speichern“ oder „In Datei schreiben“ gekoppelt — ein
unerwartet aktiver oder fehlkonfigurierter DHCP-Server kann ein ganzes
Netzsegment stören, jede dieser Aktionen soll ein bewusster, separater
Klick bleiben.
- **Mehrere Subnetze gleichzeitig**: ein Host kann mehrere IPs/Interfaces
mit jeweils eigenem Netz haben, für die alle DHCP angeboten werden soll
(`dhcp_subnets`-Tabelle statt einer einzelnen globalen Range) — jedes
Subnetz hat eigene Interface/Range sowie optional eigenes Gateway/DNS,
generiert wird ein eigener `subnet4`-Block je Subnetz in der Kea-Config.
Domain, Lease-Zeiten und Ausgabepfad bleiben global (Kea-weit gültig).
- **Netzwerk-Kopplung**: Subnet und Netzmaske werden **nie manuell
eingegeben**, sondern bei jeder Anzeige/Generierung live aus der
System-Netzwerkkonfiguration des gewählten Interfaces gelesen
(`_detect_interface_network()`: `ip -4 addr show dev <iface>` +
`ip route show default`). Die generierte Kea-Konfiguration ist dadurch
immer an das Netz gekoppelt, in dem der Host tatsächlich hängt, auch wenn
sich dessen IP/Subnet mal ändert — nur das Interface selbst (Dropdown,
echte vorhandene Interfaces aus `/sys/class/net`), die Lease-Range,
DNS-Server, Domain, Lease-Zeiten und der Ausgabepfad sind konfigurierbar
(`dhcp_*`-Schlüssel in der `settings`-Tabelle).
System-Netzwerkkonfiguration des jeweiligen Interfaces gelesen
(`_detect_interface_network()`/`_detect_interface_networks()`:
`ip -4 addr show dev <iface>` + `ip route show default`) — berücksichtigt
dabei ALLE IPv4-Adressen eines Interfaces, nicht nur die erste (ein
Interface kann mehrere IPs/Subnetze gleichzeitig tragen, z.B. Alias-IPs).
Die generierte Kea-Konfiguration ist dadurch immer an das Netz gekoppelt,
in dem der Host tatsächlich hängt, auch wenn sich dessen IP/Subnet mal
ändert.
- **Range ist Pflicht und muss zu einem echten Netz passen**: kein
vorausgefüllter Default für Range/DNS (nur Platzhaltertext) — ein
Subnetz ohne zu einem tatsächlich vorhandenen Netz passende Range wird
beim Anlegen/Bearbeiten abgelehnt (`_dhcp_matching_network()`), da Kea
ohne eine echte, physisch vorhandene IP im gewünschten Bereich keine
gültige Subnet-CIDR zugeordnet werden kann. Dieselbe Prüfung läuft
zusätzlich bei „In Datei schreiben“ (Subnetze, die inzwischen zu keinem
echten Netz mehr passen — z.B. nach einem Netzwechsel des Hosts — werden
übersprungen und in der Erfolgsmeldung benannt, statt die ganze Aktion
abzubrechen) und bei „Aktivieren & (neu) starten“ (verweigert den Start,
falls kein einziges gültiges Subnetz mehr existiert). Live auf einer
echten Fehlkonfiguration reproduziert und verifiziert.
- **Eigene Options** (`dhcp_option_defs`/`dhcp_option_values`, analog den
„Vordefinierten Optionen und Werten“ eines Windows-DHCP-Servers): Code,
Name, Kea-Datentyp und Beschreibung frei definierbar (z.B. herstellerspezifische
@@ -252,33 +274,25 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client
(technisch: globale Werte landen im Top-Level `option-data`, Overrides im
`option-data` der jeweiligen Kea-Reservierung, was Kea automatisch pro
Client Vorrang geben lässt).
- **Reservierungen**: für jedes aktive Gerät mit gültiger MAC + IP **innerhalb
des erkannten Subnets** wird eine Kea-Reservierung erzeugt; der
- **Reservierungen**: für jedes aktive Gerät mit gültiger MAC + IP wird eine
Kea-Reservierung im **passenden Subnetz** erzeugt (die IP entscheidet, zu
welchem der ggf. mehreren konfigurierten Subnetze ein Gerät gehört); der
Hostname wird aus dem Gerätenamen abgeleitet (nur `a-z0-9-`, Kollisionen
bekommen automatisch ein `-2`/`-3`-Suffix). Geräte ohne MAC/IP oder mit
einer IP außerhalb des Subnets werden mit Hinweis übersprungen — Kea lehnt
eine Reservierung außerhalb ihres Subnets als Konfigurationsfehler ab
(mit einer echten Kea-Instanz verifiziert).
innerhalb desselben Subnetzes bekommen automatisch ein `-2`/`-3`-Suffix).
Geräte ohne MAC/IP oder mit einer IP außerhalb aller konfigurierten
Subnetze werden mit Hinweis übersprungen — Kea lehnt eine Reservierung
außerhalb ihres Subnets als Konfigurationsfehler ab (mit einer echten
Kea-Instanz verifiziert).
- **Aktive Leases**: eigene Karte, liest die tatsächlich vergebenen Adressen
direkt aus Keas Lease-Datenbank (`kea-leases4.csv`, memfile-Backend —
kein Control-Agent nötig, reiner Lesezugriff auf die Datei). Zeigt damit
auch Clients **ohne eigene Reservierung**, die sich einfach eine freie IP
aus dem Pool genommen haben — deutlich als „Ohne Reservierung“ markiert,
statt nur die (unvollständige) Reservierungsliste zu zeigen.
- **Schreiben**: „In Datei schreiben“ (nur mit `settings_dhcp.edit`)
schreibt die vollständige, generierte Kea-JSON-Konfiguration an den
konfigurierten Ausgabepfad. Wirksam wird sie erst nach einem
Dienst-Neustart über den separaten Button.
- **Range ist Pflicht und muss zu einem echten Netz passen**: kein
vorausgefüllter Default für Range/DNS mehr (nur Platzhaltertext) — ohne
eingetragene Range schreibt „In Datei schreiben“ nichts und „Aktivieren &
(neu) starten“ verweigert den Start. Zusätzlich wird die Range beim
Speichern, Schreiben UND Starten gegen die tatsächlich am gewählten
Interface vorhandenen IPv4-Netze geprüft (ein Interface kann mehrere IPs/
Subnetze gleichzeitig tragen, z.B. Alias-IPs — alle werden berücksichtigt).
Passt die Range zu keinem davon (z.B. weil sich das Netz des Hosts seit
dem letzten Speichern geändert hat), wird die Aktion mit einer genauen
Fehlermeldung abgelehnt, statt den Dienst mit einer zum echten Netz nicht
passenden Range laufen zu lassen — live auf einer echten Fehlkonfiguration
reproduziert und verifiziert. Gateway ist separat und optional
überschreibbar (Default: automatisch erkanntes Gateway des Interfaces).
(Mehrere DHCP-Ranges gleichzeitig — z.B. auf unterschiedlichen Interfaces
— sind aktuell nicht unterstützt, die Konfiguration ist auf eine globale
Range/ein Interface ausgelegt; als nächster Ausbauschritt vorgesehen.)
- **Ampel im Topbar**: neben dem Prüfintervall-Timer zeigt ein grüner/roter
Punkt, ob der Kea-Dienst läuft — nur sichtbar mit `settings_dhcp.view`.
- Reservierungen mit aktiven eigenen Options zeigen in der Tabelle, welche
@@ -290,6 +304,23 @@ Installation, generierte Konfiguration besteht `kea-dhcp4 -t`, Dienststart
sowie Client-spezifischer Override erscheinen korrekt getrennt in der
Konfiguration.
Die Mehrfach-Subnetz-Unterstützung selbst (Anlegen/Bearbeiten/Löschen samt
Validierung, mehrere generierte `subnet4`-Blöcke mit korrekten Pools/
Interfaces, Migration einer bestehenden Einzel-Range) ist live auf einem
echten Host verifiziert. Zwei tatsächlich **unabhängige** Netze (z.B. über
zwei physische NICs) standen beim Test nicht zur Verfügung — verifiziert
wurde daher zusätzlich zwei Ranges innerhalb desselben real vorhandenen
Netzes (identischer `subnet`-CIDR, unterschiedliche Pools); ob Kea zwei
`subnet4`-Blöcke mit identischem `subnet`-Wert akzeptiert oder als Fehler
ablehnt, wurde nicht gegen eine echte Kea-Instanz geprüft — bei tatsächlich
unterschiedlichen Netzen (der eigentliche Anwendungsfall) besteht dieses
Risiko nicht. Die Leases-Anzeige wurde gegen eine echte (leere)
`kea-leases4.csv` verifiziert (korrektes Parsen, keine Endlos-/Absturz-
Fälle) — eine tatsächlich unreservierte Lease eines fremden Clients live
zu erzeugen und die „Ohne Reservierung“-Markierung damit End-to-End zu
zeigen, wurde nicht getestet (hätte einen echten, unkontrollierten
DHCP-Request auf dem gemeinsam genutzten Testnetz erfordert).
## Netzwerkeinstellungen des Hosts (Systemeinstellungen)
Eigene, klar getrennte Funktion von obigem DHCP-*Server*: hier geht es um
@@ -326,13 +357,16 @@ da eine Änderung nicht die Erreichbarkeit kappt).
erreichbar ist), rollt die App automatisch auf die gesicherte
Konfiguration zurück. Verhindert, dass ein Tippfehler bei IP/Gateway den
Host von der Web-UI/SSH abschneidet.
- **DHCP-Server wird vorsorglich gestoppt**: eine vorher passende Range
(siehe DHCP-Konfiguration oben) kann nach einer IP-/Subnetzänderung
plötzlich zum falschen Netz gehören. Läuft Kea beim Anwenden oder beim
Auto-Rollback, wird der Dienst deshalb gestoppt (nicht automatisch neu
gestartet — die Range muss der Admin aktiv auf der DHCP-Seite prüfen und
den Dienst dort bewusst wieder starten, statt dass die App mit einer
möglicherweise falschen Konfiguration weiterläuft oder blind rät).
- **DHCP-Server wird vorsorglich gestoppt UND deaktiviert**: eine vorher
passende Range (siehe DHCP-Konfiguration oben) kann nach einer
IP-/Subnetzänderung plötzlich zum falschen Netz gehören. Läuft Kea beim
Anwenden oder beim Auto-Rollback, wird der Dienst deshalb gestoppt und
deaktiviert (nicht automatisch neu gestartet — die Range muss der Admin
aktiv auf der DHCP-Seite prüfen und den Dienst dort bewusst wieder
starten). Das zusätzliche Deaktivieren verhindert, dass ein Host-Neustart
vor dieser Prüfung den Dienst trotzdem automatisch mit der ggf. falschen
Range wieder hochfährt, statt dass die App mit einer möglicherweise
falschen Konfiguration weiterläuft oder blind rät.
- Live gegen eine echte Ubuntu-24.04-VM verifiziert (Standard-Installation,
Netz per DHCP über netplan/systemd-networkd bezogen) — sowohl die
Erkennung/Statusanzeige als auch der komplette Anwenden-Pfad inkl. der
+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 %}