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:
2026-08-11 08:28:11 +02:00
co-authored by Claude Sonnet 5
parent c3d69a9870
commit 8e4a874943
3 changed files with 132 additions and 60 deletions
+11 -5
View File
@@ -235,10 +235,16 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client
oder „In Datei schreiben“ gekoppelt — ein unerwartet aktiver oder oder „In Datei schreiben“ gekoppelt — ein unerwartet aktiver oder
fehlkonfigurierter DHCP-Server kann ein ganzes Netzsegment stören, jede fehlkonfigurierter DHCP-Server kann ein ganzes Netzsegment stören, jede
dieser Aktionen soll ein bewusster, separater Klick bleiben. dieser Aktionen soll ein bewusster, separater Klick bleiben.
- **Konfiguration**: Interface, Subnet, Netzmaske, Range, DNS-Server, - **Netzwerk-Kopplung**: Subnet, Netzmaske und Gateway werden **nie manuell
Domain, Lease-Zeiten sowie der Ausgabepfad der generierten Kea-Config eingegeben**, sondern bei jeder Anzeige/Generierung live aus der
(Standard: die aktive `/etc/kea/kea-dhcp4.conf`) — als `dhcp_*`-Schlüssel System-Netzwerkkonfiguration des gewählten Interfaces gelesen
in der bestehenden `settings`-Tabelle gespeichert. (`_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 - **Eigene Options** (`dhcp_option_defs`/`dhcp_option_values`, analog den
„Vordefinierten Optionen und Werten“ eines Windows-DHCP-Servers): Code, „Vordefinierten Optionen und Werten“ eines Windows-DHCP-Servers): Code,
Name, Kea-Datentyp und Beschreibung frei definierbar (z.B. herstellerspezifische 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 `option-data` der jeweiligen Kea-Reservierung, was Kea automatisch pro
Client Vorrang geben lässt). Client Vorrang geben lässt).
- **Reservierungen**: für jedes aktive Gerät mit gültiger MAC + IP **innerhalb - **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 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 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 einer IP außerhalb des Subnets werden mit Hinweis übersprungen — Kea lehnt
+97 -45
View File
@@ -1226,8 +1226,6 @@ DHCP_SERVICE = "kea-dhcp4-server"
DHCP_DEFAULTS = { DHCP_DEFAULTS = {
"dhcp_interface": "eth0", "dhcp_interface": "eth0",
"dhcp_subnet": "192.168.1.0",
"dhcp_netmask": "255.255.255.0",
"dhcp_range_start": "192.168.1.100", "dhcp_range_start": "192.168.1.100",
"dhcp_range_end": "192.168.1.200", "dhcp_range_end": "192.168.1.200",
"dhcp_dns": "192.168.1.1", "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} 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): def _dhcp_safe_hostname(name, mac):
"""Wandelt einen Client-Namen in einen für Kea-Reservierungen/hostname """Wandelt einen Client-Namen in einen für Kea-Reservierungen/hostname
gültigen Bezeichner um (nur a-z0-9-, keine führenden/abschließenden/ 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:] 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 """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 DHCP-Reservierung erzeugen lässt. Geräte ohne rpi_ip/mac werden
übersprungen statt eine kaputte Reservierung zu erzeugen. Ebenso übersprungen statt eine kaputte Reservierung zu erzeugen. Ebenso
übersprungen: Geräte, deren IP außerhalb des konfigurierten Subnets übersprungen: Geräte, deren IP außerhalb des (aus der System-
liegt — Kea lehnt eine Reservierung außerhalb ihres subnet4-Blocks als Netzwerkkonfiguration erkannten) Subnets liegt — Kea lehnt eine
Konfigurationsfehler ab (mit einem Live-Test gegen echtes Kea Reservierung außerhalb ihres subnet4-Blocks als Konfigurationsfehler ab
verifiziert), eine solche Reservierung würde also nie eine gültige (mit einem Live-Test gegen echtes Kea verifiziert). `network` ist ein
Konfiguration ergeben.""" ipaddress.IPv4Network oder None, falls die Erkennung fehlgeschlagen ist
try: (dann werden alle Geräte als "außerhalb" übersprungen, statt eine mit
network = ipaddress.IPv4Network(f"{cfg['dhcp_subnet']}/{_dhcp_prefix_length(cfg['dhcp_netmask'])}", strict=False) Sicherheit ungültige Konfiguration zu erzeugen)."""
except ValueError:
network = None
rows = conn.execute( 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" "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() ).fetchall()
@@ -1358,25 +1405,18 @@ def _dhcp_option_values(conn):
return values return values
def _dhcp_prefix_length(netmask): def _render_kea_config(cfg, net_info, reservations, option_defs, option_values):
"""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):
"""Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON-serialisierbar). """Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON-serialisierbar).
Globale Options landen im Top-Level option-data (gilt für alle Subnetze), Subnet/Netzmaske kommen bewusst NICHT aus manueller Eingabe, sondern aus
Client-Overrides im option-data der jeweiligen Reservierung — Kea net_info (siehe _detect_interface_network) — die Konfiguration koppelt
überschreibt damit automatisch gleichnamige globale/Subnet-Options für sich damit immer an das Netz, in dem der gewählte Interface tatsächlich
genau diesen Client, ohne dass wir das selbst zusammenrechnen müssen.""" hängt. Globale Options landen im Top-Level option-data (gilt für alle
prefix = _dhcp_prefix_length(cfg["dhcp_netmask"]) 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()) 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 = [ option_def_list = [
{"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"} {"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": [ "subnet4": [
{ {
"id": 1, "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']}"}], "pools": [{"pool": f"{cfg['dhcp_range_start']} - {cfg['dhcp_range_end']}"}],
"reservations": kea_reservations, "reservations": kea_reservations,
} }
], ] if net_info.get("ok") else [],
} }
} }
@@ -1458,12 +1498,16 @@ def settings_dhcp():
return redirect(url_for("settings_dhcp")) return redirect(url_for("settings_dhcp"))
if "save_dhcp_config" in request.form: if "save_dhcp_config" in request.form:
for key in DHCP_DEFAULTS: submitted_interface = request.form.get("dhcp_interface", "").strip()
value = request.form.get(key, "").strip() if submitted_interface and submitted_interface not in _list_network_interfaces():
if value: flash(f"Interface „{submitted_interface}“ existiert nicht auf diesem Host.", "danger")
set_setting(key, value) else:
log_action("settings.update", "DHCP-Konfiguration") for key in DHCP_DEFAULTS:
flash("DHCP-Konfiguration gespeichert.", "success") value = request.form.get(key, "").strip()
if value:
set_setting(key, value)
log_action("settings.update", "DHCP-Konfiguration")
flash("DHCP-Konfiguration gespeichert.", "success")
elif "add_dhcp_option" in request.form: elif "add_dhcp_option" in request.form:
code_raw = request.form.get("code", "").strip() code_raw = request.form.get("code", "").strip()
@@ -1536,19 +1580,24 @@ def settings_dhcp():
elif "write_dhcp_file" in request.form: elif "write_dhcp_file" in request.form:
cfg = _load_dhcp_settings() 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_defs = _dhcp_option_defs(conn)
option_values = _dhcp_option_values(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"] output_path = cfg["dhcp_output_path"]
try: try:
os.makedirs(os.path.dirname(output_path), exist_ok=True) os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f: with open(output_path, "w", encoding="utf-8") as f:
f.write(content + "\n") 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( flash(
f"{len(reservations)} Reservierungen nach {output_path} geschrieben. " f"{len(reservations)} Reservierungen für Netz {net_info['network']} (Interface {cfg['dhcp_interface']}) "
f"Der Dienst übernimmt die Änderung erst nach einem Neustart (Button unten) — " 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.", f"das führt die App bewusst nicht automatisch mit aus.",
"success", "success",
) )
@@ -1593,7 +1642,8 @@ def settings_dhcp():
return redirect(url_for("settings_dhcp")) return redirect(url_for("settings_dhcp"))
cfg = _load_dhcp_settings() 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_defs = _dhcp_option_defs(conn)
option_values = _dhcp_option_values(conn) option_values = _dhcp_option_values(conn)
skipped_count = conn.execute( skipped_count = conn.execute(
@@ -1601,11 +1651,13 @@ def settings_dhcp():
).fetchone()["n"] ).fetchone()["n"]
conn.close() conn.close()
status = _detect_dhcp_status() 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( return render_template(
"settings_dhcp.html", "settings_dhcp.html",
cfg=cfg, cfg=cfg,
status=status, status=status,
net_info=net_info,
interfaces=_list_network_interfaces(),
reservations=reservations, reservations=reservations,
skipped_count=skipped_count, skipped_count=skipped_count,
out_of_subnet_count=out_of_subnet_count, out_of_subnet_count=out_of_subnet_count,
+24 -10
View File
@@ -59,30 +59,44 @@
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Netzwerk-Konfiguration</h2> <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>
</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 %} {% if can_edit %}
<form method="post"> <form method="post">
<input type="hidden" name="save_dhcp_config" value="1"> <input type="hidden" name="save_dhcp_config" value="1">
<div class="field"><label>Interface</label> <div class="field"><label>Interface</label>
<input type="text" name="dhcp_interface" value="{{ cfg.dhcp_interface }}"> <select name="dhcp_interface">
</div> {% for iface in interfaces %}
<div class="field"><label>Subnet</label> <option value="{{ iface }}" {% if iface == cfg.dhcp_interface %}selected{% endif %}>{{ iface }}</option>
<input type="text" name="dhcp_subnet" value="{{ cfg.dhcp_subnet }}"> {% endfor %}
</div> </select>
<div class="field"><label>Netzmaske</label> <div class="field-hint">Subnet/Netzmaske/Gateway oben werden für das hier ausgewählte Interface erkannt.</div>
<input type="text" name="dhcp_netmask" value="{{ cfg.dhcp_netmask }}">
</div> </div>
<div class="field"><label>Range Start</label> <div class="field"><label>Range Start</label>
<input type="text" name="dhcp_range_start" value="{{ cfg.dhcp_range_start }}"> <input type="text" name="dhcp_range_start" value="{{ cfg.dhcp_range_start }}">
</div> </div>
<div class="field"><label>Range Ende</label> <div class="field"><label>Range Ende</label>
<input type="text" name="dhcp_range_end" value="{{ cfg.dhcp_range_end }}"> <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>
<div class="field"><label>DNS-Server</label> <div class="field"><label>DNS-Server</label>
<input type="text" name="dhcp_dns" value="{{ cfg.dhcp_dns }}"> <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>
<div class="field"><label>Domain</label> <div class="field"><label>Domain</label>
<input type="text" name="dhcp_domain" value="{{ cfg.dhcp_domain }}"> <input type="text" name="dhcp_domain" value="{{ cfg.dhcp_domain }}">
@@ -173,7 +187,7 @@
<div class="hint"> <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 im konfigurierten Subnet.
{% if skipped_count %}{{ skipped_count }} aktive{{ ' Clients' if skipped_count != 1 else 'r Client' }} ohne MAC/IP übersprungen.{% endif %} {% 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>
</div> </div>
{% if can_edit %} {% if can_edit %}