DHCP: Subnet/Netzmaske/Gateway immer aus System-Netzwerkkonfiguration ableiten
- Neue Helfer _list_network_interfaces() (echte Interfaces aus /sys/class/net) und _detect_interface_network() (IPv4/Prefix per "ip addr show", Gateway per "ip route show default") — Subnet/ Netzmaske/Router werden dadurch bei jeder Anzeige/Generierung live vom System gelesen statt manuell gepflegt zu werden. - dhcp_subnet/dhcp_netmask als manuelle Settings entfernt; Interface ist jetzt ein Dropdown mit den tatsächlich vorhandenen Interfaces statt Freitext, serverseitig zusätzlich gegen die echte Liste validiert. - _dhcp_reservation_candidates()/_render_kea_config() nehmen jetzt das erkannte Netz (ipaddress.IPv4Network) bzw. net_info entgegen statt Subnet/Netzmaske aus der Konfiguration zu lesen; Router kommt vom erkannten Gateway (Fallback: erster DNS-Eintrag, wie zuvor). - UI zeigt das erkannte Netz (IP/Prefix, Subnet, Gateway) read-only an; Schreiben/Vorschau werden blockiert bzw. liefern eine leere Reservierungsliste, wenn die Erkennung fehlschlägt, statt eine mit Sicherheit falsche Konfiguration zu erzeugen. - Live getestet: Erkennung liefert korrekt das tatsächliche WSL-NAT-Netz (172.25.64.0/20) samt Gateway; generierte Config besteht kea-dhcp4 -t; ein Testgerät mit IP im erkannten Netz erscheint korrekt als Reservierung, Geräte außerhalb werden weiterhin sauber übersprungen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -235,10 +235,16 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client
|
||||
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.
|
||||
- **Konfiguration**: Interface, Subnet, Netzmaske, Range, DNS-Server,
|
||||
Domain, Lease-Zeiten sowie der Ausgabepfad der generierten Kea-Config
|
||||
(Standard: die aktive `/etc/kea/kea-dhcp4.conf`) — als `dhcp_*`-Schlüssel
|
||||
in der bestehenden `settings`-Tabelle gespeichert.
|
||||
- **Netzwerk-Kopplung**: Subnet, Netzmaske und Gateway 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).
|
||||
- **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
|
||||
@@ -249,7 +255,7 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client
|
||||
`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 konfigurierten Subnets** wird eine Kea-Reservierung erzeugt; der
|
||||
des erkannten Subnets** wird eine Kea-Reservierung erzeugt; 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
|
||||
|
||||
+91
-39
@@ -1226,8 +1226,6 @@ DHCP_SERVICE = "kea-dhcp4-server"
|
||||
|
||||
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",
|
||||
@@ -1291,6 +1289,58 @@ def _detect_dhcp_status():
|
||||
return {"installed": installed, "path": kea_path, "active": active, "enabled": enabled, "version": version}
|
||||
|
||||
|
||||
def _list_network_interfaces():
|
||||
"""Echte Netzwerkschnittstellen dieses Hosts (ohne loopback) — Quelle
|
||||
für die Interface-Auswahl, damit nur tatsächlich vorhandene Interfaces
|
||||
wählbar sind statt eines frei eintippbaren Textfelds."""
|
||||
try:
|
||||
return sorted(name for name in os.listdir("/sys/class/net") if name != "lo")
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _detect_interface_network(interface):
|
||||
"""Liest Subnet, Netzmaske und Gateway direkt aus der laufenden
|
||||
System-Netzwerkkonfiguration des gewählten Interfaces aus (statt sie
|
||||
manuell pflegen zu lassen) — dadurch koppelt sich die generierte
|
||||
DHCP-Konfiguration immer an das Netz, in dem der Host tatsächlich
|
||||
hängt, auch wenn sich dessen IP/Subnet mal ändert."""
|
||||
result = {"ok": False, "ip": None, "prefix": None, "netmask": None, "network": None, "gateway": None, "error": None}
|
||||
if not interface:
|
||||
result["error"] = "Kein Interface ausgewählt."
|
||||
return result
|
||||
try:
|
||||
addr_out = subprocess.run(
|
||||
["ip", "-4", "-o", "addr", "show", "dev", interface],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
match = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/(\d+)", addr_out.stdout)
|
||||
if not match:
|
||||
result["error"] = f"Keine IPv4-Adresse auf „{interface}“ gefunden."
|
||||
return result
|
||||
ip_str, prefix = match.group(1), int(match.group(2))
|
||||
network = ipaddress.IPv4Network(f"{ip_str}/{prefix}", strict=False)
|
||||
result.update(ok=True, ip=ip_str, prefix=prefix, network=network, netmask=str(network.netmask))
|
||||
|
||||
route_out = subprocess.run(
|
||||
["ip", "-4", "route", "show", "default", "dev", interface],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
gw_match = re.search(r"via (\d+\.\d+\.\d+\.\d+)", route_out.stdout)
|
||||
if not gw_match:
|
||||
# Fallback: irgendeine Default-Route, falls keine speziell an
|
||||
# dieses Interface gebunden ist (typisch bei Hosts mit nur
|
||||
# einer aktiven Schnittstelle).
|
||||
route_out = subprocess.run(
|
||||
["ip", "-4", "route", "show", "default"], capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
gw_match = re.search(r"via (\d+\.\d+\.\d+\.\d+)", route_out.stdout)
|
||||
result["gateway"] = gw_match.group(1) if gw_match else None
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
return result
|
||||
|
||||
|
||||
def _dhcp_safe_hostname(name, mac):
|
||||
"""Wandelt einen Client-Namen in einen für Kea-Reservierungen/hostname
|
||||
gültigen Bezeichner um (nur a-z0-9-, keine führenden/abschließenden/
|
||||
@@ -1304,20 +1354,17 @@ def _dhcp_safe_hostname(name, mac):
|
||||
return "geraet-" + re.sub(r"[^0-9a-f]", "", mac.lower())[-8:]
|
||||
|
||||
|
||||
def _dhcp_reservation_candidates(conn, cfg):
|
||||
def _dhcp_reservation_candidates(conn, network):
|
||||
"""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. Ebenso
|
||||
übersprungen: Geräte, deren IP außerhalb des konfigurierten Subnets
|
||||
liegt — Kea lehnt eine Reservierung außerhalb ihres subnet4-Blocks als
|
||||
Konfigurationsfehler ab (mit einem Live-Test gegen echtes Kea
|
||||
verifiziert), eine solche Reservierung würde also nie eine gültige
|
||||
Konfiguration ergeben."""
|
||||
try:
|
||||
network = ipaddress.IPv4Network(f"{cfg['dhcp_subnet']}/{_dhcp_prefix_length(cfg['dhcp_netmask'])}", strict=False)
|
||||
except ValueError:
|
||||
network = None
|
||||
|
||||
übersprungen: Geräte, deren IP außerhalb des (aus der System-
|
||||
Netzwerkkonfiguration erkannten) Subnets liegt — Kea lehnt eine
|
||||
Reservierung außerhalb ihres subnet4-Blocks als Konfigurationsfehler ab
|
||||
(mit einem Live-Test gegen echtes Kea verifiziert). `network` ist ein
|
||||
ipaddress.IPv4Network oder None, falls die Erkennung fehlgeschlagen ist
|
||||
(dann werden alle Geräte als "außerhalb" übersprungen, statt eine mit
|
||||
Sicherheit ungültige Konfiguration 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()
|
||||
@@ -1358,25 +1405,18 @@ def _dhcp_option_values(conn):
|
||||
return values
|
||||
|
||||
|
||||
def _dhcp_prefix_length(netmask):
|
||||
"""Wandelt eine punktierte Netzmaske in die CIDR-Bitlänge für Keas
|
||||
"subnet"-Notation um. Fällt bei ungültiger Eingabe auf /24 zurück,
|
||||
statt die Konfigurationserzeugung mit einer Exception abzubrechen."""
|
||||
try:
|
||||
return ipaddress.IPv4Network(f"0.0.0.0/{netmask}").prefixlen
|
||||
except (ValueError, ipaddress.NetmaskValueError):
|
||||
return 24
|
||||
|
||||
|
||||
def _render_kea_config(cfg, reservations, option_defs, option_values):
|
||||
def _render_kea_config(cfg, net_info, reservations, option_defs, option_values):
|
||||
"""Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON-serialisierbar).
|
||||
Globale Options landen im Top-Level option-data (gilt für alle Subnetze),
|
||||
Client-Overrides im option-data der jeweiligen Reservierung — Kea
|
||||
überschreibt damit automatisch gleichnamige globale/Subnet-Options für
|
||||
genau diesen Client, ohne dass wir das selbst zusammenrechnen müssen."""
|
||||
prefix = _dhcp_prefix_length(cfg["dhcp_netmask"])
|
||||
Subnet/Netzmaske kommen bewusst NICHT aus manueller Eingabe, sondern aus
|
||||
net_info (siehe _detect_interface_network) — die Konfiguration koppelt
|
||||
sich damit immer an das Netz, in dem der gewählte Interface tatsächlich
|
||||
hängt. Globale Options landen im Top-Level option-data (gilt für alle
|
||||
Subnetze), Client-Overrides im option-data der jeweiligen Reservierung
|
||||
— Kea überschreibt damit automatisch gleichnamige globale/Subnet-
|
||||
Options für genau diesen Client, ohne dass wir das selbst
|
||||
zusammenrechnen müssen."""
|
||||
dns_servers = ", ".join(s.strip() for s in cfg["dhcp_dns"].split(",") if s.strip())
|
||||
router = cfg["dhcp_dns"].split(",")[0].strip() if cfg["dhcp_dns"] else ""
|
||||
router = net_info.get("gateway") or (cfg["dhcp_dns"].split(",")[0].strip() if cfg["dhcp_dns"] else "")
|
||||
|
||||
option_def_list = [
|
||||
{"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"}
|
||||
@@ -1422,11 +1462,11 @@ def _render_kea_config(cfg, reservations, option_defs, option_values):
|
||||
"subnet4": [
|
||||
{
|
||||
"id": 1,
|
||||
"subnet": f"{cfg['dhcp_subnet']}/{prefix}",
|
||||
"subnet": f"{net_info['network'].network_address}/{net_info['network'].prefixlen}",
|
||||
"pools": [{"pool": f"{cfg['dhcp_range_start']} - {cfg['dhcp_range_end']}"}],
|
||||
"reservations": kea_reservations,
|
||||
}
|
||||
],
|
||||
] if net_info.get("ok") else [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1458,6 +1498,10 @@ def settings_dhcp():
|
||||
return redirect(url_for("settings_dhcp"))
|
||||
|
||||
if "save_dhcp_config" in request.form:
|
||||
submitted_interface = request.form.get("dhcp_interface", "").strip()
|
||||
if submitted_interface and submitted_interface not in _list_network_interfaces():
|
||||
flash(f"Interface „{submitted_interface}“ existiert nicht auf diesem Host.", "danger")
|
||||
else:
|
||||
for key in DHCP_DEFAULTS:
|
||||
value = request.form.get(key, "").strip()
|
||||
if value:
|
||||
@@ -1536,19 +1580,24 @@ def settings_dhcp():
|
||||
|
||||
elif "write_dhcp_file" in request.form:
|
||||
cfg = _load_dhcp_settings()
|
||||
reservations, _ = _dhcp_reservation_candidates(conn, cfg)
|
||||
net_info = _detect_interface_network(cfg["dhcp_interface"])
|
||||
if not net_info["ok"]:
|
||||
flash(f"Netzwerk-Erkennung für „{cfg['dhcp_interface']}“ fehlgeschlagen: {net_info['error']} — nichts geschrieben.", "danger")
|
||||
conn.close()
|
||||
return redirect(url_for("settings_dhcp"))
|
||||
reservations, _ = _dhcp_reservation_candidates(conn, net_info["network"])
|
||||
option_defs = _dhcp_option_defs(conn)
|
||||
option_values = _dhcp_option_values(conn)
|
||||
content = json.dumps(_render_kea_config(cfg, reservations, option_defs, option_values), indent=2, ensure_ascii=False)
|
||||
content = json.dumps(_render_kea_config(cfg, net_info, reservations, option_defs, option_values), indent=2, ensure_ascii=False)
|
||||
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 + "\n")
|
||||
log_action("settings.dhcp_write", output_path, f"{len(reservations)} Reservierungen")
|
||||
log_action("settings.dhcp_write", output_path, f"{len(reservations)} Reservierungen, Netz {net_info['network']}")
|
||||
flash(
|
||||
f"{len(reservations)} Reservierungen nach {output_path} geschrieben. "
|
||||
f"Der Dienst übernimmt die Änderung erst nach einem Neustart (Button unten) — "
|
||||
f"{len(reservations)} Reservierungen für Netz {net_info['network']} (Interface {cfg['dhcp_interface']}) "
|
||||
f"nach {output_path} geschrieben. Der Dienst übernimmt die Änderung erst nach einem Neustart (Button unten) — "
|
||||
f"das führt die App bewusst nicht automatisch mit aus.",
|
||||
"success",
|
||||
)
|
||||
@@ -1593,7 +1642,8 @@ def settings_dhcp():
|
||||
return redirect(url_for("settings_dhcp"))
|
||||
|
||||
cfg = _load_dhcp_settings()
|
||||
reservations, out_of_subnet_count = _dhcp_reservation_candidates(conn, cfg)
|
||||
net_info = _detect_interface_network(cfg["dhcp_interface"])
|
||||
reservations, out_of_subnet_count = _dhcp_reservation_candidates(conn, net_info.get("network"))
|
||||
option_defs = _dhcp_option_defs(conn)
|
||||
option_values = _dhcp_option_values(conn)
|
||||
skipped_count = conn.execute(
|
||||
@@ -1601,11 +1651,13 @@ def settings_dhcp():
|
||||
).fetchone()["n"]
|
||||
conn.close()
|
||||
status = _detect_dhcp_status()
|
||||
preview = json.dumps(_render_kea_config(cfg, reservations, option_defs, option_values), indent=2, ensure_ascii=False)
|
||||
preview = json.dumps(_render_kea_config(cfg, net_info, reservations, option_defs, option_values), indent=2, ensure_ascii=False)
|
||||
return render_template(
|
||||
"settings_dhcp.html",
|
||||
cfg=cfg,
|
||||
status=status,
|
||||
net_info=net_info,
|
||||
interfaces=_list_network_interfaces(),
|
||||
reservations=reservations,
|
||||
skipped_count=skipped_count,
|
||||
out_of_subnet_count=out_of_subnet_count,
|
||||
|
||||
@@ -59,30 +59,44 @@
|
||||
<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 class="hint">Subnet, Netzmaske und Gateway werden immer live aus der System-Netzwerkkonfiguration des gewählten Interfaces übernommen — nicht manuell gepflegt, damit sie nie aus dem Ruder laufen können.</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>
|
||||
<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 }}">
|
||||
<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 }}">
|
||||
</div>
|
||||
<div class="field"><label>Range Ende</label>
|
||||
<input type="text" name="dhcp_range_end" value="{{ cfg.dhcp_range_end }}">
|
||||
<div class="field-hint">Muss innerhalb des oben erkannten Netzes liegen.</div>
|
||||
</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. Der erste Eintrag wird auch als Router/Gateway verwendet.</div>
|
||||
<div class="field-hint">Kommagetrennt, falls mehrere. Router/Gateway kommt vom erkannten Netz oben; nur falls dort keins gefunden wird, wird ersatzweise der erste DNS-Eintrag verwendet.</div>
|
||||
</div>
|
||||
<div class="field"><label>Domain</label>
|
||||
<input type="text" name="dhcp_domain" value="{{ cfg.dhcp_domain }}">
|
||||
@@ -173,7 +187,7 @@
|
||||
<div class="hint">
|
||||
{{ reservations|length }} aktive{{ ' Clients' if reservations|length != 1 else 'r Client' }} mit gültiger MAC + IP im konfigurierten Subnet.
|
||||
{% 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 konfigurierten Subnets „{{ cfg.dhcp_subnet }}“ übersprungen — Kea lehnt Reservierungen außerhalb ihres Subnets ab.{% 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{% if net_info.network %} „{{ net_info.network }}“{% endif %} übersprungen — Kea lehnt Reservierungen außerhalb ihres Subnets ab.{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
|
||||
Reference in New Issue
Block a user