DHCP: Subnetze deaktivierbar + 47 verifizierte Standard-Optionen

1. Subnetze koennen einzeln deaktiviert werden (enabled-Spalte), ohne sie
   loeschen zu muessen -- werden dann bei "In Datei schreiben" und
   "Aktivieren & (neu) starten" komplett uebersprungen. Bewusst nicht als
   "ungueltig" gewertet (kein Fehler/keine Warnung), da eine deaktivierte
   Zeile absichtlich kein echtes Netz braucht. Toggle-Button in der
   Subnetz-Tabelle, deaktivierte Zeilen abgeblendet mit "Deaktiviert"-Pill.

2. 47 kuratierte Standard-DHCP-Optionen vorbefuellt (analog den
   "Predefined Options" eines Windows-DHCP-Servers) -- Router/DNS-Server/
   Domain-Name/Lease-Zeiten ausgenommen, dafuer gibt es bereits eigene
   Felder. Alle Namen live gegen eine echte Kea-2.4.1-Instanz verifiziert:
   jede der 49 Kandidaten-Optionen einzeln per kea-dhcp4 -t getestet, 47
   akzeptiert (2 falsch geratene Namen verworfen: nis-domain-name statt
   nis-domain, nisplus-domain statt nisplus-domain-name).

   Wichtig fuer Kea-Kompatibilitaet: Standard-Optionen bekommen KEIN
   eigenes option-def in der generierten Konfiguration (neue is_standard-
   Spalte in dhcp_option_defs) -- Kea kennt sie bereits nativ, eine
   Neudefinition haette die eingebaute dupliziert. Nur echte eigene/
   herstellerspezifische Options bekommen weiterhin ein option-def.
   Live verifiziert: option-def bleibt bei gesetztem Standard-Options-Wert
   leer, option-data enthaelt den Wert korrekt, kea-dhcp4 -t besteht,
   echter Dienstneustart mit der Config erfolgreich.

   Einmalige Migration (Guard wie bei den anderen Schema-Migrationen) --
   ein vom Admin bewusst geloeschter Standard-Eintrag wird nach einem
   Neustart nicht stillschweigend wieder angelegt.

Ausserdem in dieser Session zuvor gefixt (siehe vorherige Commits): der
falsche Kea-Schluessel lease-file->name, der jeden Dienststart blockierte,
sowie der 'Jetzt pruefen'-Button, der den Topbar-Countdown nur auf dem
Dashboard aktualisierte.
This commit is contained in:
2026-08-11 15:06:55 +02:00
parent 182457629d
commit 9885a579ea
3 changed files with 175 additions and 21 deletions
+122 -7
View File
@@ -491,6 +491,64 @@ def get_db_connection():
return conn
# Kuratierte Standard-DHCPv4-Optionen (analog den "Predefined Options" eines
# Windows-DHCP-Servers) — vorbefüllt in _ensure_schema(), siehe dort für die
# Begründung. Muss vor dem _ensure_schema()-Aufruf am Ende der Datei stehen.
# (code, Kea-Name, Anzeige-Typ, Beschreibung). Router (3)/DNS-Server (6)/
# Domain-Name (15)/Lease-Zeiten (51/58/59) sind bereits eigene, dedizierte
# Felder und deshalb bewusst nicht enthalten. Alle 47 Namen live gegen eine
# echte Kea-2.4.1-Instanz verifiziert (kea-dhcp4 -t je Option einzeln).
DHCP_STANDARD_OPTIONS = [
(4, "time-servers", "ipv4-address", "Zeitserver (RFC 868)"),
(7, "log-servers", "ipv4-address", "Log-Server (MIT-LCS UDP)"),
(8, "cookie-servers", "ipv4-address", "Cookie-Server"),
(9, "lpr-servers", "ipv4-address", "LPR-Druckserver"),
(10, "impress-servers", "ipv4-address", "Impress-Server"),
(11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
(16, "swap-server", "ipv4-address", "Swap-Server"),
(17, "root-path", "string", "Root-Pfad (NFS)"),
(18, "extensions-path", "string", "Extensions-Pfad (BOOTP)"),
(19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
(20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
(26, "interface-mtu", "uint16", "MTU des Interfaces"),
(27, "all-subnets-local", "boolean", "Alle Subnetze lokal erreichbar"),
(28, "broadcast-address", "ipv4-address", "Broadcast-Adresse"),
(29, "perform-mask-discovery", "boolean", "Subnetzmasken-Discovery"),
(30, "mask-supplier", "boolean", "Subnetzmasken-Anbieter"),
(31, "router-discovery", "boolean", "Router Discovery (RFC 1256)"),
(32, "router-solicitation-address", "ipv4-address", "Router-Solicitation-Adresse"),
(33, "static-routes", "string", "Statische Routen (Ziel,Gateway,...)"),
(34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"),
(35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"),
(36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"),
(37, "default-tcp-ttl", "uint8", "Standard-TTL für TCP"),
(38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"),
(40, "nis-domain", "string", "NIS-Domain"),
(41, "nis-servers", "ipv4-address", "NIS-Server"),
(42, "ntp-servers", "ipv4-address", "NTP-Server"),
(44, "netbios-name-servers", "ipv4-address", "WINS/NBNS-Server"),
(45, "netbios-dd-server", "ipv4-address", "NetBIOS Datagram Distribution Server"),
(46, "netbios-node-type", "uint8", "WINS/NBT-Knotentyp"),
(47, "netbios-scope", "string", "NetBIOS-Scope-ID"),
(48, "font-servers", "ipv4-address", "X-Window-Font-Server"),
(49, "x-display-manager", "ipv4-address", "X-Window Display Manager"),
(64, "nisplus-domain-name", "string", "NIS+-Domain"),
(65, "nisplus-servers", "ipv4-address", "NIS+-Server"),
(66, "tftp-server-name", "string", "Boot-Server (TFTP-Hostname)"),
(67, "boot-file-name", "string", "Bootdatei-Name"),
(68, "mobile-ip-home-agent", "ipv4-address", "Mobile-IP-Home-Agent"),
(69, "smtp-server", "ipv4-address", "SMTP-Server"),
(70, "pop-server", "ipv4-address", "POP3-Server"),
(71, "nntp-server", "ipv4-address", "NNTP-Server"),
(72, "www-server", "ipv4-address", "WWW-Server"),
(73, "finger-server", "ipv4-address", "Finger-Server"),
(74, "irc-server", "ipv4-address", "IRC-Server"),
(75, "streettalk-server", "ipv4-address", "StreetTalk-Server"),
(76, "streettalk-directory-assistance-server", "ipv4-address", "StreetTalk Directory Assistance Server"),
(119, "domain-search", "string", "DNS-Suchliste (Domain Search)"),
]
def _ensure_schema():
"""Legt neue Tabellen (Gruppen/Rechte) an, falls die DB noch aus einer
älteren Version stammt — idempotent, sicher bei jedem Start aufzurufen.
@@ -590,9 +648,15 @@ def _ensure_schema():
code INTEGER NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
description TEXT
description TEXT,
is_standard INTEGER NOT NULL DEFAULT 0
)
""")
# Migration: "is_standard"-Spalte nachrüsten, falls die Tabelle noch aus
# einer Version ohne vorbefüllte Standard-Optionen stammt.
option_def_cols = {row["name"] for row in conn.execute("PRAGMA table_info(dhcp_option_defs)").fetchall()}
if "is_standard" not in option_def_cols:
conn.execute("ALTER TABLE dhcp_option_defs ADD COLUMN is_standard INTEGER NOT NULL DEFAULT 0")
conn.execute("""
CREATE TABLE IF NOT EXISTS dhcp_option_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -603,6 +667,26 @@ def _ensure_schema():
UNIQUE (option_def_id, device_mac)
)
""")
# Einmalig eine kuratierte Auswahl an Standard-DHCP-Optionen vorbefüllen
# (analog den "Predefined Options" eines Windows-DHCP-Servers) — Router/
# DNS-Server/Domain-Name/Lease-Zeiten sind bereits eigene, dedizierte
# Felder (siehe dhcp_subnets/DHCP_DEFAULTS) und deshalb hier bewusst
# ausgenommen. Alle Namen live gegen eine echte Kea-2.4.1-Instanz
# verifiziert (kea-dhcp4 -t) — Kea kennt diese Standard-Optionen bereits
# nativ, sie bekommen deshalb (anders als eigene Options) KEIN eigenes
# option-def in der generierten Konfiguration (siehe _render_kea_config),
# sonst würde die eingebaute Definition dupliziert. Per Guard nur
# einmalig, damit ein bewusst vom Admin gelöschter Eintrag nach einem
# Neustart nicht stillschweigend wieder auftaucht.
_migration_key_std_opts = "_seeded_dhcp_standard_options_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts,)).fetchone():
for code, name, opt_type, description in DHCP_STANDARD_OPTIONS:
conn.execute(
"INSERT OR IGNORE INTO dhcp_option_defs (code, name, type, description, is_standard) "
"VALUES (?, ?, ?, ?, 1)",
(code, name, opt_type, description),
)
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts,))
# DHCP: mehrere gleichzeitige Subnetze statt einer einzelnen globalen
# Range — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz
@@ -618,9 +702,15 @@ def _ensure_schema():
range_end TEXT NOT NULL,
gateway TEXT,
dns TEXT,
sort_order INTEGER NOT NULL DEFAULT 0
sort_order INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1
)
""")
# Migration: "enabled"-Spalte nachrüsten, falls die Tabelle noch aus
# einer Version ohne Deaktivieren-Funktion stammt.
dhcp_subnet_cols = {row["name"] for row in conn.execute("PRAGMA table_info(dhcp_subnets)").fetchall()}
if "enabled" not in dhcp_subnet_cols:
conn.execute("ALTER TABLE dhcp_subnets ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1")
# Migration: bisherige globale Einzel-Range (dhcp_interface/_range_*/
# _gateway/_dns in der settings-Tabelle) einmalig in eine erste Zeile
# der neuen Tabelle überführen, statt eine bereits funktionierende
@@ -1912,11 +2002,16 @@ def _dhcp_valid_subnets(subnets):
"""Nimmt die rohen dhcp_subnets-Zeilen und validiert JEDE einzeln gegen
ihr tatsächliches Netz (siehe _dhcp_subnet_matches — es muss eine
physische IP im gewünschten Bereich vorhanden sein, sonst kann Kea
damit nichts anfangen). Gibt (valid, invalid) zurück: valid ist um
"net_info" angereichert (für Reservierungs-Zuordnung und Kea-Config),
invalid enthält (Zeile, Fehlertext) für die Anzeige/Warnungen."""
damit nichts anfangen). Deaktivierte Subnetze (enabled=0) werden dabei
komplett übersprungen — bewusst nicht als "invalid" gewertet, da das
Fehlen eines echten Netzes hier kein Fehler ist, sondern Absicht.
Gibt (valid, invalid) zurück: valid ist um "net_info" angereichert (für
Reservierungs-Zuordnung und Kea-Config), invalid enthält (Zeile,
Fehlertext) für die Anzeige/Warnungen."""
valid, invalid = [], []
for s in subnets:
if not s["enabled"]:
continue
net_info, error = _dhcp_subnet_matches(s)
if error:
invalid.append((s, error))
@@ -1975,7 +2070,9 @@ def _dhcp_reservation_candidates(conn, valid_subnets):
def _dhcp_option_defs(conn):
return conn.execute("SELECT id, code, name, type, description FROM dhcp_option_defs ORDER BY code ASC").fetchall()
return conn.execute(
"SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs ORDER BY code ASC"
).fetchall()
def _dhcp_option_values(conn):
@@ -2045,9 +2142,13 @@ def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs,
Reservierung — Kea überschreibt damit automatisch gleichnamige
globale/Subnet-Options für genau diesen Client, ohne dass wir das
selbst zusammenrechnen müssen."""
# Standard-Optionen (is_standard) bekommen KEIN eigenes option-def — Kea
# kennt sie bereits nativ, eine Neudefinition würde die eingebaute
# duplizieren (siehe DHCP_STANDARD_OPTIONS). Nur eigene Options brauchen
# ein option-def, damit Kea Name/Typ/Code überhaupt kennt.
option_def_list = [
{"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"}
for d in option_defs
for d in option_defs if not d["is_standard"]
]
global_option_data = []
@@ -2201,6 +2302,20 @@ def settings_dhcp():
log_action("settings.dhcp_subnet_delete", subnet_id)
flash("Subnetz gelöscht.", "success")
elif "toggle_dhcp_subnet" in request.form:
subnet_id = request.form.get("toggle_dhcp_subnet")
row = conn.execute("SELECT enabled, interface FROM dhcp_subnets WHERE id=?", (subnet_id,)).fetchone()
if row:
new_state = 0 if row["enabled"] else 1
conn.execute("UPDATE dhcp_subnets SET enabled=? WHERE id=?", (new_state, subnet_id))
conn.commit()
log_action("settings.dhcp_subnet_toggle", row["interface"], "aktiviert" if new_state else "deaktiviert")
flash(
"Subnetz aktiviert." if new_state else
"Subnetz deaktiviert — wird beim nächsten Schreiben/Starten nicht mehr berücksichtigt.",
"success",
)
elif "add_dhcp_option" in request.form:
code_raw = request.form.get("code", "").strip()
name = request.form.get("name", "").strip()
+27 -5
View File
@@ -113,13 +113,15 @@
<tbody>
{% for s in subnets %}
{% set st = subnet_status.get(s.id) %}
<tr>
<tr {% if not s.enabled %}style="opacity:.55;"{% endif %}>
<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 %}
{% if not s.enabled %}
<span class="pill disabled">Deaktiviert</span>
{% elif st and st.ok %}
<span class="pill online">Erkannt</span>
<span class="mono text-faint" style="font-size:11.5px;">{{ st.network }}</span>
{% else %}
@@ -130,6 +132,16 @@
<td>
{% if can_edit %}
<div class="flex gap-2">
<form method="post">
<input type="hidden" name="toggle_dhcp_subnet" value="{{ s.id }}">
<button type="submit" class="icon-btn" title="{{ 'Deaktivieren' if s.enabled else 'Aktivieren' }}">
{% if s.enabled %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M4.9 4.9l14.2 14.2"/></svg>
{% else %}
<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>
{% endif %}
</button>
</form>
<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>
@@ -156,8 +168,11 @@
<div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Eigene DHCP-Options</h2>
<div class="hint">Herstellerspezifische Options, global oder pro Client überschreibbar.</div>
<h2 style="font-size:16px;">DHCP-Options</h2>
<div class="hint">
Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt,
eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.
</div>
</div>
{% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
@@ -171,13 +186,20 @@
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Code</th><th>Name</th><th>Typ</th><th>Beschreibung</th><th>Globaler Wert</th><th style="width:1%;">Aktionen</th></tr></thead>
<thead><tr><th>Code</th><th>Name</th><th>Typ</th><th>Herkunft</th><th>Beschreibung</th><th>Globaler Wert</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for d in option_defs %}
<tr>
<td class="mono">{{ d.code }}</td>
<td class="mono">{{ d.name }}</td>
<td>{{ d.type }}</td>
<td>
{% if d.is_standard %}
<span class="pill" style="background:var(--muted-dim); color:var(--text-faint);">Standard</span>
{% else %}
<span class="pill user">Eigen</span>
{% endif %}
</td>
<td class="text-faint" style="font-size:12px;">{{ d.description or '—' }}</td>
<td>
{% if can_edit %}