Neu: Netzwerkeinstellungen des Hosts unter Systemeinstellungen (IP/DNS/DHCP)

Bisher enthielt "Systemeinstellungen" nur das Pruefintervall. Jetzt
zusaetzlich Verwaltung der Netzwerkkonfiguration DIESES Hosts (getrennt
vom Kea-DHCP-*Server* fuer Clients):

- Backend-Erkennung (NetworkManager vs. dhcpcd via systemctl is-active).
  Kein erkanntes Backend -> Seite bleibt rein lesend statt zu raten.
- Status-Anzeige: Interface, IP/Prefix, Gateway, DNS, Modus (DHCP/Statisch).
- Aenderung: Interface, Modus, bei Statisch IP/Prefix/Gateway, DNS
  unabhaengig von Modus setzbar (IP per DHCP + feste DNS moeglich).
- Sicherheitsnetz analog "netplan try": Backup vor jeder Aenderung,
  automatisches Rollback nach 45s ohne explizite Bestaetigung (nur
  moeglich, wenn die Seite ueber die neue Config noch erreichbar ist) --
  verhindert Aussperren durch einen Tippfehler bei IP/Gateway.
- Bugfix waehrend der Implementierung gefunden: der Auto-Revert-Timer
  laeuft in einem Hintergrund-Thread ohne Request-Kontext: log_action()
  griff auf current_user zu und warf dort einen AttributeError (die
  Config wurde trotzdem korrekt zurueckgerollt, nur der Audit-Log-Eintrag
  fehlte und ein Fehler landete im Server-Log). Fix: log_action_system()
  ohne current_user-Abhaengigkeit fuer Code ausserhalb des Request-
  Kontexts.
- Getestet: Backend-Erkennung + rein lesender Fallback live (WSL hat
  weder NetworkManager noch dhcpcd aktiv, korrekt erkannt), Rechte-Gating,
  Anwenden-/Rollback-Logik fuer beide Backends per gemocktem
  subprocess.run verifiziert (inkl. echtem dhcpcd.conf-Rewrite und
  vollständigem Anwenden-dann-Auto-Rollback-Durchlauf mit Audit-Log-
  Eintrag).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 10:50:33 +02:00
co-authored by Claude Sonnet 5
parent 197af26d72
commit 82edf7628b
3 changed files with 419 additions and 30 deletions
+266 -7
View File
@@ -813,6 +813,20 @@ def log_action(action, target=None, details=None):
conn.close()
def log_action_system(action, target=None, details=None):
"""Wie log_action(), aber ohne Abhängigkeit von current_user — für
Code, der außerhalb eines Request-Kontexts läuft (z.B. der
Netzwerk-Auto-Revert-Timer in einem eigenen Thread, wo current_user
nicht auflösbar ist und einen AttributeError werfen würde)."""
conn = get_db_connection()
conn.execute(
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "system", action, target, details),
)
conn.commit()
conn.close()
def touch_record(conn, table, key_col, key_val):
"""Trägt 'zuletzt geändert von/am' direkt am Datensatz ein (Devices/Switches)."""
who = current_user.username if current_user.is_authenticated else "system"
@@ -1214,6 +1228,192 @@ def run_check_now():
# Settings
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# System-Netzwerkeinstellungen (IP/DNS/DHCP DIESES Hosts — nicht zu
# verwechseln mit dem Kea-DHCP-*Server* für Client-Geräte weiter unten).
#
# Höchste Vorsicht geboten: eine falsche Änderung hier kann die Erreichbarkeit
# dieses Hosts (SSH/Web-UI) komplett kappen. Deshalb:
# - Erkennung des tatsächlich aktiven Netzwerk-Backends (NetworkManager
# oder dhcpcd) statt blind eine Methode anzunehmen; ist keins von beiden
# aktiv (z.B. in dieser WSL-Testumgebung), bleibt die Seite bewusst rein
# lesend statt zu raten.
# - Vor jeder Änderung wird die vorherige Konfiguration gesichert.
# - Nach dem Anwenden läuft ein Sicherheits-Timer im Hintergrund: wird die
# neue Konfiguration nicht innerhalb von NETWORK_REVERT_SECONDS explizit
# bestätigt (das ist nur möglich, wenn die Web-UI über die NEUE
# Konfiguration noch erreichbar ist), wird automatisch die gesicherte
# Konfiguration wiederhergestellt — analog zu "netplan try".
# ---------------------------------------------------------------------------
NETWORK_REVERT_SECONDS = 45
_pending_network_revert = {} # {"timer": Timer, "backend":..., "interface":..., "backup": {...}}
def _detect_network_backend():
"""Rein lesend: welcher der beiden gängigen Netzwerk-Manager auf
Debian/Raspberry Pi OS ist aktiv? Keine Annahme, falls keins von
beiden läuft (z.B. in dieser WSL-Testumgebung, die ihr Netz über den
Hyper-V-Adapter bezieht) — dann bleibt die Seite bewusst rein lesend."""
for service, name in (("NetworkManager", "networkmanager"), ("dhcpcd", "dhcpcd")):
try:
result = subprocess.run(["systemctl", "is-active", service], capture_output=True, text=True, timeout=5)
if result.stdout.strip() == "active":
return name
except Exception:
pass
return "unknown"
def _read_resolv_conf_dns():
try:
with open("/etc/resolv.conf", encoding="utf-8") as f:
return [line.split()[1] for line in f if line.strip().startswith("nameserver")]
except OSError:
return []
def _read_network_state(interface, backend):
"""Aktueller Ist-Zustand — IP/Prefix/Gateway aus _detect_interface_network
(bereits für die DHCP-Server-Funktion gebaut), DNS aus resolv.conf,
Modus (dhcp/static) backend-spezifisch ermittelt. Jeder Erkennungsschritt
ist defensiv (try/except) — im Zweifel lieber "unbekannt" anzeigen als
einen falschen Zustand zu behaupten."""
net_info = _detect_interface_network(interface)
mode = "unknown"
if backend == "networkmanager":
try:
dev_out = subprocess.run(
["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface],
capture_output=True, text=True, timeout=5,
)
conn_name = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None
if conn_name:
method_out = subprocess.run(
["nmcli", "-g", "ipv4.method", "connection", "show", conn_name],
capture_output=True, text=True, timeout=5,
)
mode = "static" if method_out.stdout.strip() == "manual" else "dhcp"
except Exception:
pass
elif backend == "dhcpcd":
try:
with open("/etc/dhcpcd.conf", encoding="utf-8") as f:
content = f.read()
block_match = re.search(rf"^interface\s+{re.escape(interface)}\s*$(.*?)(?=^interface\s|\Z)", content, re.S | re.M)
block = block_match.group(1) if block_match else ""
mode = "static" if re.search(r"^\s*static\s+ip_address=", block, re.M) else "dhcp"
except OSError:
mode = "dhcp"
return {**net_info, "dns": _read_resolv_conf_dns(), "mode": mode, "backend": backend}
def _backup_network_config(interface, backend):
"""Kompletten Ist-Zustand vor einer Änderung sichern, damit
_revert_network_config() ihn exakt wiederherstellen kann."""
state = _read_network_state(interface, backend)
backup = {
"interface": interface, "backend": backend, "mode": state["mode"],
"ip": state.get("ip"), "prefix": state.get("prefix"), "gateway": state.get("gateway"),
"dns": state.get("dns", []),
}
if backend == "dhcpcd":
try:
with open("/etc/dhcpcd.conf", encoding="utf-8") as f:
backup["dhcpcd_conf"] = f.read()
except OSError:
backup["dhcpcd_conf"] = None
elif backend == "networkmanager":
try:
dev_out = subprocess.run(
["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface],
capture_output=True, text=True, timeout=5,
)
backup["nm_connection"] = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None
except Exception:
backup["nm_connection"] = None
return backup
def _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_list):
"""Wendet die neue Konfiguration über das erkannte Backend an. Gibt
(ok, message) zurück statt zu werfen, damit der Aufrufer immer eine
Flash-Meldung bekommt statt eines 500ers."""
dns_csv = ",".join(dns_list)
if backend == "networkmanager":
try:
dev_out = subprocess.run(
["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface],
capture_output=True, text=True, timeout=5,
)
conn_name = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None
if not conn_name:
return False, f"Keine aktive NetworkManager-Verbindung für {interface} gefunden."
args = ["nmcli", "connection", "modify", conn_name]
if mode == "static":
args += ["ipv4.method", "manual", "ipv4.addresses", f"{ip}/{prefix}", "ipv4.gateway", gateway]
else:
args += ["ipv4.method", "auto", "ipv4.addresses", "", "ipv4.gateway", ""]
if dns_csv:
args += ["ipv4.dns", dns_csv, "ipv4.ignore-auto-dns", "yes"]
else:
args += ["ipv4.dns", "", "ipv4.ignore-auto-dns", "no"]
ok, out = _dhcp_run_privileged(args, timeout=15)
if not ok:
return False, out
return _dhcp_run_privileged(["nmcli", "connection", "up", conn_name], timeout=20)
except Exception as e:
return False, str(e)
if backend == "dhcpcd":
try:
with open("/etc/dhcpcd.conf", encoding="utf-8") as f:
content = f.read()
content = re.sub(rf"^interface\s+{re.escape(interface)}\s*$(.*?)(?=^interface\s|\Z)", "", content, flags=re.S | re.M)
block_lines = [f"interface {interface}"]
if mode == "static":
block_lines.append(f"static ip_address={ip}/{prefix}")
if gateway:
block_lines.append(f"static routers={gateway}")
if dns_list:
block_lines.append(f"static domain_name_servers={' '.join(dns_list)}")
if len(block_lines) > 1:
content = content.rstrip() + "\n\n" + "\n".join(block_lines) + "\n"
with open("/etc/dhcpcd.conf", "w", encoding="utf-8") as f:
f.write(content)
return _dhcp_run_privileged(["systemctl", "restart", "dhcpcd"], timeout=20)
except Exception as e:
return False, str(e)
return False, "Kein unterstütztes Netzwerk-Backend erkannt (weder NetworkManager noch dhcpcd aktiv)."
def _revert_network_config(token):
"""Timer-Callback: falls binnen NETWORK_REVERT_SECONDS keine Bestätigung
einging, gesicherten Zustand wiederherstellen. Läuft in einem
Hintergrund-Thread (threading.Timer) — Fehler werden geloggt statt die
App zum Absturz zu bringen."""
entry = _pending_network_revert.get(token)
if not entry:
return
backup = entry["backup"]
try:
if backup["backend"] == "dhcpcd" and backup.get("dhcpcd_conf") is not None:
with open("/etc/dhcpcd.conf", "w", encoding="utf-8") as f:
f.write(backup["dhcpcd_conf"])
subprocess.run(["systemctl", "restart", "dhcpcd"], timeout=20)
elif backup["backend"] == "networkmanager":
_apply_network_config(
"networkmanager", backup["interface"],
backup["mode"], backup.get("ip"), backup.get("prefix"), backup.get("gateway"), backup.get("dns", []),
)
log_action_system("settings.network_revert", backup["interface"], "automatisch nach Timeout zurückgerollt")
except Exception as e:
app.logger.error("Network auto-revert failed: %s", e)
finally:
_pending_network_revert.pop(token, None)
@app.route("/settings", methods=["GET", "POST"])
@login_required
def settings():
@@ -1227,16 +1427,75 @@ def settings():
if not current_user.has_permission("settings_system.edit"):
flash("Keine Berechtigung, die Systemeinstellungen zu ändern.", "danger")
return redirect(url_for("settings"))
new_interval = int(request.form["interval"])
set_setting("interval", new_interval)
set_setting("check_interval", new_interval * 60)
log_action("settings.update", "Prüfintervall", f"{new_interval} Minuten")
_restart_check_service()
flash(f"Intervall auf {new_interval} Minuten gesetzt und Service neu gestartet!", "success")
if "interval" in request.form:
new_interval = int(request.form["interval"])
set_setting("interval", new_interval)
set_setting("check_interval", new_interval * 60)
log_action("settings.update", "Prüfintervall", f"{new_interval} Minuten")
_restart_check_service()
flash(f"Intervall auf {new_interval} Minuten gesetzt und Service neu gestartet!", "success")
elif "apply_network" in request.form:
interface = request.form.get("net_interface", "").strip()
mode = request.form.get("net_mode", "dhcp")
ip = request.form.get("net_ip", "").strip()
prefix = request.form.get("net_prefix", "").strip()
gateway = request.form.get("net_gateway", "").strip()
dns_list = [d.strip() for d in request.form.get("net_dns", "").split(",") if d.strip()]
backend = _detect_network_backend()
if interface not in _list_network_interfaces():
flash(f"Interface „{interface}“ existiert nicht auf diesem Host.", "danger")
elif backend == "unknown":
flash("Kein unterstütztes Netzwerk-Backend erkannt — Änderungen über die App sind deaktiviert.", "danger")
elif mode == "static" and not (ip and prefix and gateway):
flash("Für eine statische Konfiguration werden IP-Adresse, Prefix und Gateway benötigt.", "danger")
else:
backup = _backup_network_config(interface, backend)
ok, out = _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_list)
if ok:
token = secrets.token_hex(8)
timer = threading.Timer(NETWORK_REVERT_SECONDS, _revert_network_config, args=(token,))
timer.daemon = True
_pending_network_revert.clear() # nur eine ausstehende Änderung gleichzeitig
_pending_network_revert[token] = {"timer": timer, "backup": backup}
timer.start()
log_action("settings.network_apply", interface, f"Modus {mode}")
flash(
f"Netzwerkkonfiguration angewendet. Falls diese Seite jetzt noch erreichbar ist, bitte "
f"unten bestätigen — sonst wird nach {NETWORK_REVERT_SECONDS}s automatisch zurückgerollt.",
"success",
)
else:
flash(f"Anwenden fehlgeschlagen: {out}", "danger")
elif "confirm_network" in request.form:
token = request.form.get("confirm_network")
entry = _pending_network_revert.pop(token, None)
if entry:
entry["timer"].cancel()
log_action("settings.network_confirm", entry["backup"]["interface"])
flash("Netzwerkkonfiguration bestätigt — kein automatisches Rollback mehr.", "success")
else:
flash("Keine ausstehende Bestätigung gefunden (evtl. bereits abgelaufen).", "danger")
return redirect(url_for("settings"))
return render_template("settings.html", interval=interval)
net_backend = _detect_network_backend()
net_interface = get_setting("net_interface") or (_list_network_interfaces() or [None])[0]
net_state = _read_network_state(net_interface, net_backend) if net_interface else None
pending_token = next(iter(_pending_network_revert), None)
return render_template(
"settings.html",
interval=interval,
net_backend=net_backend,
net_interfaces=_list_network_interfaces(),
net_interface=net_interface,
net_state=net_state,
net_revert_seconds=NETWORK_REVERT_SECONDS,
pending_network_token=pending_token,
)
@app.route("/settings/import-export")