GUI: Config-Profile, Reservierungen und Extended Options hinzufügen

This commit is contained in:
2025-12-27 09:52:27 +01:00
parent eebc5296e0
commit 95fbd14918
2 changed files with 573 additions and 83 deletions

View File

@@ -18,10 +18,19 @@ Benutze dieses Tool nur in isolierten Testumgebungen oder auf einem dedizierten
- liest IP & Subnetz des gewählten Interface automatisch aus
- vergibt Adressen aus einem dynamischen Pool (ab Start-IP)
- **Neu:** Eingabefelder für Primary/Secondary DNS (DHCP Option 6)
- Lease-Time optional per Extended Option 51 oder pro Reservierung
- **Config-Profiles**: Interface/IP/DNS + Reservierungen/Extended Options als JSON speichern & laden
- **Statische Reservierungen**: MAC → IP Tabelle in der GUI, wird vom Server beachtet
- **Leases-Tab**: Restlaufzeit sichtbar, Direkt-Button „Add“ um Leases in Reservierungen zu übernehmen
- **Optionale PXE-Infos**: DHCP Option 66 (TFTP-Server) und 67 (Bootfile), wenn gesetzt
- **DHCP-Optionen:**
- Option 1: Subnetzmaske
- Option 3: Router/Gateway (setzt automatisch auf Server-IP)
- Option 6: DNS-Server (aus GUI)
- Option 42: NTP-Server (kommagetrennte IPs)
- Option 15: DNS-Suffix
- Option 51: Lease Time (Pflicht; Default 3600s, kann in Extended Options gesetzt werden)
- Option 66/67: TFTP-Server & Bootfile
- Option 51: Lease-Time
- **Log-Panel & Statusleiste**: zeigt DHCP-Events in Echtzeit
- **Echte DHCP-Pakete**: DISCOVER → OFFER, REQUEST → ACK, RELEASE → Lease-Freigabe
@@ -143,4 +152,4 @@ Leases werden nur im Speicher gehalten (kein persistentes Lease-File).
## ⚠️ Haftungsausschluss
Dies ist ein **Entwicklungs- und Lern-Tool**. Es ist *nicht* als Ersatz für produktive DHCP-Server gedacht.
Benutzung auf eigene Gefahr prüfe deine Netzumgebung sorgfältig, bevor du den Server startest!
Benutzung auf eigene Gefahr prüfe deine Netzumgebung sorgfältig, bevor du den Server startest!

View File

@@ -1,10 +1,14 @@
import ipaddress
import json
import os
import socket
import time
import tkinter as tk
from tkinter import messagebox, ttk
from typing import Optional, List
from tkinter import filedialog, messagebox, ttk
from typing import Dict, List, Optional, Tuple
from dhcp_server import DHCPServer
from option_specs import EXTENDED_OPTION_SPECS
import utils
@@ -59,64 +63,84 @@ class DHCPApp(tk.Frame):
# Single server instance
self.server: Optional[DHCPServer] = None
self.reservations: List[Dict[str, str]] = []
self.last_config_path: Optional[str] = None
self.ext_option_specs = list(EXTENDED_OPTION_SPECS)
self.ext_opt_values: Dict[str, tk.StringVar] = {}
self.ext_opt_enabled: Dict[str, tk.BooleanVar] = {}
# --- Top form ---
form = ttk.Frame(self)
form.pack(fill="x", pady=(0, 8))
# --- Toolbar ---
toolbar = ttk.Frame(self)
toolbar.pack(fill="x", pady=(0, 6))
self.btn_start = ttk.Button(toolbar, text="Start", command=self.start_server)
self.btn_stop = ttk.Button(toolbar, text="Stop", command=self.stop_server, state="disabled")
self.btn_rescan = ttk.Button(toolbar, text="Interfaces neu scannen", command=self.rescan_interfaces)
self.btn_load = ttk.Button(toolbar, text="Config laden", command=self.load_config)
self.btn_save = ttk.Button(toolbar, text="Config speichern", command=self.save_config)
self.btn_forcerenew = ttk.Button(toolbar, text="Force Renew", command=self.force_renew_all)
self.btn_start.pack(side="left", padx=(0, 6))
self.btn_stop.pack(side="left", padx=(0, 12))
self.btn_rescan.pack(side="left", padx=(0, 12))
self.btn_load.pack(side="left", padx=(0, 6))
self.btn_save.pack(side="left")
self.btn_forcerenew.pack(side="left", padx=(12, 0))
# Zeile 0
ttk.Label(form, text="Interface:").grid(row=0, column=0, sticky="w")
# --- Interface left, IP settings right ---
top = ttk.Frame(self)
top.pack(fill="x", pady=(0, 10), anchor="w")
# Left: Interface box with dropdown + info
iface_box = ttk.Labelframe(top, text="Interface")
iface_box.pack(side="left", padx=(0, 8), fill="y")
iface_box.update_idletasks()
iface_box.configure(width=400)
iface_box.pack_propagate(False)
iface_box.columnconfigure(1, weight=1)
ttk.Label(iface_box, text="Interface:").grid(row=0, column=0, sticky="w", padx=(8, 4), pady=(8, 6))
self.if_var = tk.StringVar()
self.if_menu = ttk.OptionMenu(form, self.if_var, None, command=self.on_iface_change)
self.if_menu.grid(row=0, column=1, sticky="ew", padx=(6, 16))
form.columnconfigure(1, weight=1)
self.if_combo = ttk.Combobox(iface_box, textvariable=self.if_var, state="readonly", width=18)
self.if_combo.grid(row=0, column=1, sticky="ew", padx=(4, 8), pady=(8, 6))
self.if_combo.bind("<<ComboboxSelected>>", self.on_iface_change)
ttk.Label(form, text="Server/Start-IP:").grid(row=0, column=2, sticky="w")
self.mac_var = tk.StringVar(value="-")
self.speed_var = tk.StringVar(value="-")
ttk.Label(iface_box, text="MAC:").grid(row=1, column=0, sticky="w", padx=(8, 4), pady=(2, 8))
ttk.Label(iface_box, textvariable=self.mac_var).grid(row=1, column=1, sticky="w", padx=(4, 8), pady=(2, 8))
ttk.Label(iface_box, text="Link:").grid(row=2, column=0, sticky="w", padx=(8, 4), pady=(0, 10))
ttk.Label(iface_box, textvariable=self.speed_var).grid(row=2, column=1, sticky="w", padx=(4, 8), pady=(0, 10))
# Right: IP settings box
ip_box = ttk.Labelframe(top, text="IP-Settings")
ip_box.pack(side="left", fill="y")
ip_box.update_idletasks()
ip_box.configure(width=420)
ip_box.pack_propagate(False)
ip_box.columnconfigure(1, weight=1)
ip_box.columnconfigure(3, weight=1)
ttk.Label(ip_box, text="Server/Start-IP:").grid(row=0, column=0, sticky="w", padx=(8, 4), pady=(8, 4))
self.ip_var = tk.StringVar(value="")
self.ip_entry = ttk.Entry(form, textvariable=self.ip_var, width=16)
self.ip_entry.grid(row=0, column=3, sticky="w", padx=(6, 16))
self.ip_entry = ttk.Entry(ip_box, textvariable=self.ip_var, width=16)
self.ip_entry.grid(row=0, column=1, sticky="w", padx=(4, 12), pady=(8, 4))
ttk.Label(form, text="Subnetzmaske:").grid(row=0, column=4, sticky="w")
ttk.Label(ip_box, text="Subnetzmaske:").grid(row=0, column=2, sticky="w", padx=(8, 4), pady=(8, 4))
self.mask_var = tk.StringVar(value="")
self.mask_entry = ttk.Entry(form, textvariable=self.mask_var, width=16)
self.mask_entry.grid(row=0, column=5, sticky="w", padx=(6, 16))
self.mask_entry = ttk.Entry(ip_box, textvariable=self.mask_var, width=16)
self.mask_entry.grid(row=0, column=3, sticky="w", padx=(4, 12), pady=(8, 4))
# Zeile 1 DNS Felder
ttk.Label(form, text="Primary DNS:").grid(row=1, column=0, sticky="w", pady=(6, 0))
ttk.Label(ip_box, text="Primary DNS:").grid(row=1, column=0, sticky="w", padx=(8, 4), pady=(4, 10))
self.dns1_var = tk.StringVar(value="")
self.dns1_entry = ttk.Entry(form, textvariable=self.dns1_var, width=16)
self.dns1_entry.grid(row=1, column=1, sticky="w", padx=(6, 16), pady=(6, 0))
self.dns1_entry = ttk.Entry(ip_box, textvariable=self.dns1_var, width=16)
self.dns1_entry.grid(row=1, column=1, sticky="w", padx=(4, 12), pady=(4, 10))
ttk.Label(form, text="Secondary DNS:").grid(row=1, column=2, sticky="w", pady=(6, 0))
ttk.Label(ip_box, text="Secondary DNS:").grid(row=1, column=2, sticky="w", padx=(8, 4), pady=(4, 10))
self.dns2_var = tk.StringVar(value="")
self.dns2_entry = ttk.Entry(form, textvariable=self.dns2_var, width=16)
self.dns2_entry.grid(row=1, column=3, sticky="w", padx=(6, 16), pady=(6, 0))
self.dns2_entry = ttk.Entry(ip_box, textvariable=self.dns2_var, width=16)
self.dns2_entry.grid(row=1, column=3, sticky="w", padx=(4, 12), pady=(4, 10))
# --- Buttons ---
btns = ttk.Frame(self)
btns.pack(fill="x", pady=(8, 8))
self.btn_start = ttk.Button(btns, text="Start", command=self.start_server)
self.btn_stop = ttk.Button(btns, text="Stop", command=self.stop_server, state="disabled")
self.btn_rescan = ttk.Button(btns, text="Interfaces neu scannen", command=self.rescan_interfaces)
self.btn_start.pack(side="left")
self.btn_stop.pack(side="left", padx=(8, 0))
self.btn_rescan.pack(side="left", padx=(8, 0))
# --- Clients & Logs Paned Layout ---
self.paned = ttk.Panedwindow(self, orient="horizontal")
self.paned.pack(fill="both", expand=True)
# Clients list
left = ttk.Labelframe(self.paned, text="Aktive Clients / Leases")
self.clients = tk.Listbox(left, height=12)
self.clients.pack(fill="both", expand=True, padx=6, pady=6)
self.paned.add(left, weight=1)
# Logs
right = ttk.Labelframe(self.paned, text="Log")
self.log = tk.Text(right, height=12, state="disabled")
self.log.pack(fill="both", expand=True, padx=6, pady=6)
self.paned.add(right, weight=2)
# --- Notebook with tabs ---
self._build_tabs()
# --- Status bar ---
self.status_var = tk.StringVar(value="Bereit.")
@@ -125,12 +149,137 @@ class DHCPApp(tk.Frame):
self._refresh_interface_list(initial=True)
self._apply_min_sizes()
self.paned.bind("<Configure>", lambda _e: self._enforce_pane_sizes())
self.after_idle(self._enforce_pane_sizes)
# keep disabled option display in sync with GUI IP/DNS
for v in (self.ip_var, self.mask_var, self.dns1_var, self.dns2_var):
v.trace_add("write", lambda *_: self._refresh_disabled_ext_options())
self._refresh_disabled_ext_options()
# periodic refresh
self.after(400, self._refresh)
# -------------------- UI builders --------------------
def _build_tabs(self) -> None:
self.notebook = ttk.Notebook(self)
self.notebook.pack(fill="both", expand=True)
self._build_lease_tab()
self._build_log_tab()
self._build_reservations_tab()
self._build_extended_options_tab()
def _build_lease_tab(self) -> None:
tab = ttk.Frame(self.notebook)
columns = ("mac", "ip", "remaining", "action")
self.leases_tree = ttk.Treeview(tab, columns=columns, show="headings", height=10)
self.leases_tree.heading("mac", text="MAC-Adresse")
self.leases_tree.heading("ip", text="IP-Adresse")
self.leases_tree.heading("remaining", text="Restlaufzeit")
self.leases_tree.heading("action", text="Reservierung")
self.leases_tree.column("mac", width=160, anchor="w")
self.leases_tree.column("ip", width=140, anchor="w")
self.leases_tree.column("remaining", width=120, anchor="w")
self.leases_tree.column("action", width=120, anchor="w")
scroll = ttk.Scrollbar(tab, orient="vertical", command=self.leases_tree.yview)
self.leases_tree.configure(yscrollcommand=scroll.set)
self.leases_tree.pack(side="left", fill="both", expand=True, padx=6, pady=6)
scroll.pack(side="right", fill="y")
self.leases_tree.bind("<ButtonRelease-1>", self._on_lease_click)
self.notebook.add(tab, text="Leases")
def _build_log_tab(self) -> None:
tab = ttk.Frame(self.notebook)
self.log = tk.Text(tab, height=12, state="disabled")
scroll = ttk.Scrollbar(tab, orient="vertical", command=self.log.yview)
self.log.configure(yscrollcommand=scroll.set)
self.log.pack(side="left", fill="both", expand=True, padx=6, pady=6)
scroll.pack(side="right", fill="y")
self.notebook.add(tab, text="Log")
def _build_reservations_tab(self) -> None:
tab = ttk.Frame(self.notebook)
tree_frame = ttk.Frame(tab)
tree_frame.pack(fill="both", expand=True, padx=6, pady=6)
columns = ("mac", "ip", "lease", "name")
self.res_tree = ttk.Treeview(tree_frame, columns=columns, show="headings", selectmode="extended", height=8)
self.res_tree.heading("mac", text="MAC-Adresse")
self.res_tree.heading("ip", text="IP-Adresse")
self.res_tree.heading("lease", text="Lease (s)")
self.res_tree.heading("name", text="Name/Notiz")
self.res_tree.column("mac", width=150, anchor="w")
self.res_tree.column("ip", width=140, anchor="w")
self.res_tree.column("lease", width=90, anchor="w")
self.res_tree.column("name", width=200, anchor="w")
scroll = ttk.Scrollbar(tree_frame, orient="vertical", command=self.res_tree.yview)
self.res_tree.configure(yscrollcommand=scroll.set)
self.res_tree.pack(side="left", fill="both", expand=True)
scroll.pack(side="right", fill="y")
self.res_tree.bind("<<TreeviewSelect>>", self._on_reservation_select)
form = ttk.Labelframe(tab, text="Eintrag bearbeiten")
form.pack(fill="x", padx=6, pady=(0, 8))
ttk.Label(form, text="MAC:").grid(row=0, column=0, sticky="w", padx=(6, 2), pady=4)
self.res_mac_var = tk.StringVar()
ttk.Entry(form, textvariable=self.res_mac_var, width=22).grid(row=0, column=1, sticky="w", pady=4)
ttk.Label(form, text="IP:").grid(row=0, column=2, sticky="w", padx=(12, 2), pady=4)
self.res_ip_var = tk.StringVar()
ttk.Entry(form, textvariable=self.res_ip_var, width=18).grid(row=0, column=3, sticky="w", pady=4)
ttk.Label(form, text="Lease (s):").grid(row=0, column=4, sticky="w", padx=(12, 2), pady=4)
self.res_lease_var = tk.StringVar()
ttk.Entry(form, textvariable=self.res_lease_var, width=10).grid(row=0, column=5, sticky="w", pady=4)
ttk.Label(form, text="Name/Notiz:").grid(row=0, column=6, sticky="w", padx=(12, 2), pady=4)
self.res_name_var = tk.StringVar()
ttk.Entry(form, textvariable=self.res_name_var, width=22).grid(row=0, column=7, sticky="w", pady=4)
actions = ttk.Frame(form)
actions.grid(row=0, column=8, sticky="e", padx=(12, 6))
ttk.Button(actions, text="Hinzufügen/Aktualisieren", command=self.add_or_update_reservation).pack(side="left", padx=(0, 6))
ttk.Button(actions, text="Löschen", command=self.remove_reservation).pack(side="left")
self.notebook.add(tab, text="Reservierungen")
def _build_extended_options_tab(self) -> None:
tab = ttk.Frame(self.notebook)
form = ttk.Frame(tab)
form.pack(fill="x", padx=8, pady=(8, 8))
ttk.Label(form, text="Aktiv").grid(row=0, column=0, sticky="w", padx=(0, 12))
ttk.Label(form, text="Option").grid(row=0, column=1, sticky="w", padx=(0, 12))
ttk.Label(form, text="Wert").grid(row=0, column=2, sticky="w", padx=(0, 12))
ttk.Label(form, text="Beschreibung").grid(row=0, column=3, sticky="w", padx=(0, 12))
form.columnconfigure(3, weight=1)
row = 1
for spec in self.ext_option_specs:
code = spec["code"]
default_val = spec.get("default", "")
mandatory = bool(spec.get("mandatory"))
self.ext_opt_values[code] = tk.StringVar(value=default_val)
self.ext_opt_enabled[code] = tk.BooleanVar(value=mandatory)
disabled = bool(spec.get("disabled"))
desc_txt = spec.get("desc", "")
if disabled:
desc_txt = f"{desc_txt} (aus GUI/Server, gesperrt)"
chk = ttk.Checkbutton(form, variable=self.ext_opt_enabled[code])
if disabled:
chk.state(["disabled"])
chk.grid(row=row, column=0, sticky="w", pady=4)
ttk.Label(form, text=code).grid(row=row, column=1, sticky="w", pady=4)
entry = ttk.Entry(form, textvariable=self.ext_opt_values[code], width=32, state="disabled" if disabled else "normal")
entry.grid(row=row, column=2, sticky="w", padx=(0, 12), pady=4)
ttk.Label(form, text=desc_txt).grid(row=row, column=3, sticky="w", padx=(0, 12), pady=4)
row += 1
ttk.Label(tab, text="Weitere Optionen kannst du in option_specs.py ergänzen.").pack(anchor="w", padx=8, pady=(4, 8))
self.notebook.add(tab, text="Extended Options")
# -------------------- UI logic --------------------
def on_iface_change(self, _sel=None):
iface = self.if_var.get().strip()
@@ -147,10 +296,11 @@ class DHCPApp(tk.Frame):
if mask:
self.mask_var.set(mask)
# Secondary DNS bleibt leer, damit du frei wählen kannst
self._update_iface_info(iface)
def _set_controls_enabled(self, enabled: bool):
state = "normal" if enabled else "disabled"
self.if_menu.configure(state=state)
state = "readonly" if enabled else "disabled"
self.if_combo.configure(state=state)
self.ip_entry.configure(state=state)
self.mask_entry.configure(state=state)
self.dns1_entry.configure(state=state)
@@ -158,24 +308,25 @@ class DHCPApp(tk.Frame):
self.btn_rescan.configure(state=state)
self.btn_start.configure(state="normal" if enabled else "disabled")
self.btn_stop.configure(state="disabled" if enabled else "normal")
self.btn_forcerenew.configure(state="disabled" if enabled else "normal")
def _clear_iface_fields(self) -> None:
self.ip_var.set("")
self.mask_var.set("")
self.dns1_var.set("")
self.dns2_var.set("")
self.mac_var.set("-")
self.speed_var.set("-")
self._refresh_disabled_ext_options()
def _select_iface(self, iface: str) -> None:
self.if_var.set(iface)
self.on_iface_change(iface)
self.on_iface_change()
def _refresh_interface_list(self, initial: bool = False) -> None:
interfaces = utils.get_network_interfaces()
current = self.if_var.get().strip()
menu = self.if_menu["menu"]
menu.delete(0, "end")
for iface in interfaces:
menu.add_command(label=iface, command=lambda val=iface: self._select_iface(val))
self.if_combo["values"] = interfaces
if current in interfaces:
selection = current
@@ -193,30 +344,24 @@ class DHCPApp(tk.Frame):
self._clear_iface_fields()
if not initial:
self.status_var.set("Keine Netzwerk-Interfaces gefunden.")
if selection:
self._update_iface_info(selection)
self._refresh_disabled_ext_options()
def _apply_min_sizes(self) -> None:
# Ensure window cannot shrink below a comfortable layout
self.update_idletasks()
min_w = max(self.winfo_reqwidth() + 16, 720)
min_h = max(self.winfo_reqheight() + 16, 540)
min_w = max(self.winfo_reqwidth() + 16, 840)
min_h = max(self.winfo_reqheight() + 16, 580)
self.master.minsize(min_w, min_h)
def _enforce_pane_sizes(self) -> None:
# prevent panes from collapsing too far
try:
if not self.paned or not self.paned.panes():
return
total = self.paned.winfo_width()
left_min, right_min = 240, 360
if total <= left_min + right_min:
return
pos = self.paned.sashpos(0)
if pos < left_min:
self.paned.sashpos(0, left_min)
elif pos > total - right_min:
self.paned.sashpos(0, total - right_min)
except Exception:
pass
def _update_iface_info(self, iface: str) -> None:
mac, speed = utils.get_iface_hwinfo(iface)
self.mac_var.set(mac or "-")
if speed is None or speed <= 0:
self.speed_var.set("-")
else:
self.speed_var.set(f"{speed} Mbit/s")
def rescan_interfaces(self) -> None:
if self.server and self.server.is_running():
@@ -267,13 +412,28 @@ class DHCPApp(tk.Frame):
messagebox.showerror("Start fehlgeschlagen (Bind)", f"Konnte Port 67 auf {iface} nicht binden:\n{e}")
return
reservations = self._get_reservation_dict()
extended_opts = self._collect_extended_options()
try:
self._ensure_mandatory_ext_options(fail_on_missing=True)
except Exception as e:
messagebox.showerror("Pflicht-Option fehlt", str(e))
return
# --- Start ---
try:
self.server = DHCPServer()
# NEU: DNS-Liste an den Server übergeben
self.server.start(iface, ip, mask, dns_servers=dns_list)
self.server.start(
iface,
ip,
mask,
dns_servers=dns_list,
reservations=reservations,
extended_options=extended_opts,
)
self.status_var.set("Server gestartet.")
self._set_controls_enabled(False)
self.btn_forcerenew.configure(state="normal")
except Exception as e:
self.server = None
self.status_var.set("Start fehlgeschlagen.")
@@ -283,8 +443,208 @@ class DHCPApp(tk.Frame):
if self.server:
self.server.stop()
self._set_controls_enabled(True)
self.btn_forcerenew.configure(state="disabled")
self.status_var.set("Server gestoppt.")
# -------------------- Reservations --------------------
def _refresh_reservations_view(self) -> None:
for item in self.res_tree.get_children():
self.res_tree.delete(item)
for entry in self.reservations:
lease_raw = entry.get("lease_time", "")
lease = lease_raw if lease_raw is not None else ""
self.res_tree.insert("", "end", values=(entry.get("mac", ""), entry.get("ip", ""), lease, entry.get("name", "")))
def add_or_update_reservation(self) -> None:
mac = self.res_mac_var.get().strip()
ip = self.res_ip_var.get().strip()
lease = self.res_lease_var.get().strip()
name = self.res_name_var.get().strip()
if not mac or not ip:
messagebox.showerror("Fehler", "MAC und IP sind erforderlich.")
return
try:
mac_norm = utils.normalize_mac(mac)
ip_norm = utils.format_ip_address(ip)
lease_val = None
if lease:
lease_int = int(lease)
if lease_int <= 0:
raise ValueError("Lease muss > 0 sein.")
lease_val = lease_int
except Exception as e:
messagebox.showerror("Ungültige Eingabe", str(e))
return
existing = next((r for r in self.reservations if r.get("mac") == mac_norm), None)
if existing:
existing.update({"ip": ip_norm, "name": name, "lease_time": lease_val})
else:
self.reservations.append({"mac": mac_norm, "ip": ip_norm, "name": name, "lease_time": lease_val})
self._refresh_reservations_view()
def remove_reservation(self) -> None:
selected = self.res_tree.selection()
if not selected:
return
macs = {self.res_tree.item(item, "values")[0] for item in selected}
self.reservations = [r for r in self.reservations if r.get("mac") not in macs]
self._refresh_reservations_view()
def _on_reservation_select(self, _event=None) -> None:
selected = self.res_tree.selection()
if not selected:
return
vals = self.res_tree.item(selected[0], "values")
if vals:
self.res_mac_var.set(vals[0])
self.res_ip_var.set(vals[1])
self.res_lease_var.set(vals[2])
self.res_name_var.set(vals[3] if len(vals) > 3 else "")
def _get_reservation_dict(self) -> List[Dict[str, str]]:
return [
{
"mac": entry["mac"],
"ip": entry["ip"],
"name": entry.get("name", ""),
"lease_time": entry.get("lease_time"),
}
for entry in self.reservations
if entry.get("mac") and entry.get("ip")
]
def _has_reservation(self, mac: str) -> bool:
return any(r.get("mac") == mac for r in self.reservations)
# -------------------- Extended Options --------------------
def _collect_extended_options(self) -> Dict[str, str]:
opts: Dict[str, Dict[str, str]] = {}
for spec in self.ext_option_specs:
code = spec["code"]
if spec.get("disabled"):
continue
val = (self.ext_opt_values.get(code) or tk.StringVar()).get().strip()
enabled = bool((self.ext_opt_enabled.get(code) or tk.BooleanVar()).get())
if spec.get("mandatory") and not val:
continue
if (enabled or spec.get("mandatory")) and val:
opts[code] = {"value": val, "enabled": True}
return opts
# -------------------- Config load/save --------------------
def _collect_config(self) -> Dict:
dns_entries = [d for d in (self.dns1_var.get().strip(), self.dns2_var.get().strip()) if d]
return {
"interface": self.if_var.get().strip(),
"server_ip": self.ip_var.get().strip(),
"subnet_mask": self.mask_var.get().strip(),
"dns": dns_entries,
"reservations": self.reservations,
"extended_options": {
spec["code"]: {
"value": (self.ext_opt_values[spec["code"]].get().strip()),
"enabled": bool(self.ext_opt_enabled[spec["code"]].get()),
}
for spec in self.ext_option_specs
if not spec.get("disabled")
},
}
def _apply_config(self, cfg: Dict) -> None:
self.if_var.set(cfg.get("interface", ""))
self.ip_var.set(cfg.get("server_ip", ""))
self.mask_var.set(cfg.get("subnet_mask", ""))
dns = cfg.get("dns") or []
self.dns1_var.set(dns[0] if len(dns) > 0 else "")
self.dns2_var.set(dns[1] if len(dns) > 1 else "")
self._refresh_disabled_ext_options()
# reservations
self.reservations = []
for item in cfg.get("reservations") or []:
mac = item.get("mac") or ""
ip = item.get("ip") or ""
name = item.get("name") or ""
lease_time = item.get("lease_time")
try:
lease_time = int(lease_time) if lease_time not in (None, "", "None") else None
if lease_time is not None and lease_time <= 0:
lease_time = None
except Exception:
lease_time = None
try:
mac_norm = utils.normalize_mac(mac)
ip_norm = utils.format_ip_address(ip)
except Exception:
continue
self.reservations.append({"mac": mac_norm, "ip": ip_norm, "name": name, "lease_time": lease_time})
self._refresh_reservations_view()
ext = cfg.get("extended_options") or {}
# First clear
for code in self.ext_option_specs:
default_val = code.get("default", "")
self.ext_opt_values[code["code"]].set(default_val)
self.ext_opt_enabled[code["code"]].set(bool(code.get("mandatory")))
self._refresh_disabled_ext_options()
# Apply values if present (numeric keys preferred)
for code in self.ext_option_specs:
c = code["code"]
if code.get("disabled"):
continue
entry = ext.get(c)
if entry is None:
continue
if isinstance(entry, dict):
self.ext_opt_values[c].set(entry.get("value", "") or code.get("default", ""))
self.ext_opt_enabled[c].set(bool(entry.get("enabled")) or bool(code.get("mandatory")))
else:
self.ext_opt_values[c].set(str(entry))
self.ext_opt_enabled[c].set(True)
# Enforce mandatory defaults if still empty
self._ensure_mandatory_ext_options(fail_on_missing=False)
def load_config(self) -> None:
path = filedialog.askopenfilename(
title="Config laden",
filetypes=[("JSON", "*.json"), ("Alle Dateien", "*.*")],
initialfile=os.path.basename(self.last_config_path or ""),
)
if not path:
return
try:
with open(path, "r", encoding="utf-8") as f:
cfg = json.load(f)
except Exception as e:
messagebox.showerror("Fehler beim Laden", str(e))
return
self._apply_config(cfg)
self.last_config_path = path
self.status_var.set(f"Config geladen: {os.path.basename(path)}")
def save_config(self) -> None:
path = filedialog.asksaveasfilename(
title="Config speichern",
defaultextension=".json",
filetypes=[("JSON", "*.json"), ("Alle Dateien", "*.*")],
initialfile=os.path.basename(self.last_config_path or "dhcp_config.json"),
)
if not path:
return
cfg = self._collect_config()
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
except Exception as e:
messagebox.showerror("Fehler beim Speichern", str(e))
return
self.last_config_path = path
self.status_var.set(f"Config gespeichert: {os.path.basename(path)}")
# -------------------- Refresh / Log --------------------
def _append_log(self, lines):
if not lines:
return
@@ -294,14 +654,135 @@ class DHCPApp(tk.Frame):
self.log.see("end")
self.log.configure(state="disabled")
def _update_leases_view(self, leases: Dict[str, Tuple[str, float]]) -> None:
current_items = {self.leases_tree.item(i, "values")[0]: i for i in self.leases_tree.get_children()}
# remove stale
for mac in set(current_items.keys()) - set(leases.keys()):
self.leases_tree.delete(current_items[mac])
# add/update
now = time.time()
for mac, (ip, expiry) in sorted(leases.items()):
remaining = max(int(expiry - now), 0)
remaining_txt = self._format_duration(remaining)
action = "Reserviert" if self._has_reservation(mac) else "Add"
if mac in current_items:
self.leases_tree.item(current_items[mac], values=(mac, ip, remaining_txt, action))
else:
self.leases_tree.insert("", "end", values=(mac, ip, remaining_txt, action))
def _refresh(self) -> None:
# update clients
self.clients.delete(0, "end")
leases: Dict[str, Tuple[str, float]] = {}
if self.server and self.server.is_running():
leases = self.server.get_leases()
for mac, ip in leases.items():
self.clients.insert("end", f"{mac}{ip}")
leases = self.server.get_leases_with_expiry()
self.status_var.set(f"Status: {self.server.get_status()}")
# pull logs
self._append_log(self.server.pop_logs(100))
else:
self.status_var.set("Bereit.")
self._update_leases_view(leases)
self.after(400, self._refresh)
def _refresh_disabled_ext_options(self) -> None:
# Update display values for disabled, auto-managed DHCP options
dns_list = [d for d in (self.dns1_var.get().strip(), self.dns2_var.get().strip()) if d]
dns_text = ", ".join(dns_list)
auto_values = {
"1": self.mask_var.get().strip(),
"3": self.ip_var.get().strip(),
"6": dns_text,
}
for spec in self.ext_option_specs:
if not spec.get("disabled"):
continue
code = spec["code"]
if code in auto_values:
self.ext_opt_values[code].set(auto_values[code])
# -------------------- Force Renew --------------------
def force_renew_all(self) -> None:
if not self.server or not self.server.is_running():
messagebox.showinfo("Info", "Server läuft nicht.")
return
ip = self.ip_var.get().strip()
mask = self.mask_var.get().strip()
try:
net = ipaddress.IPv4Network((ip, mask), strict=False)
except Exception:
messagebox.showerror("Fehler", "Ungültiges Netz (IP/Subnetzmaske) für Force Renew.")
return
host_count = len(list(net.hosts()))
targets = max(host_count - 1, 0) # exclude server IP roughly
extra_warn = ""
if targets > 512:
extra_warn = f"\nACHTUNG: {targets} Ziele das kann dauern."
if not messagebox.askyesno(
"Warnung",
f"DHCPFORCERENEW wird an alle {targets} IPs im Subnetz {net} gesendet.\n"
"Viele Clients ignorieren das, aber es erzeugt Traffic." + extra_warn,
icon="warning",
):
return
try:
sent = self.server.force_renew_all()
self.status_var.set(f"FORCERENEW gesendet an {sent} Client(s).")
except Exception as e:
messagebox.showerror("Fehler", str(e))
# -------------------- Leases actions --------------------
def _on_lease_click(self, event) -> None:
item_id = self.leases_tree.identify_row(event.y)
col = self.leases_tree.identify_column(event.x)
if not item_id or col != "#4": # action column
return
vals = self.leases_tree.item(item_id, "values")
if len(vals) < 4:
return
mac, ip, action = vals[0], vals[1], vals[3]
if action != "Add":
return
self._add_reservation_from_lease(mac, ip)
def _add_reservation_from_lease(self, mac: str, ip: str) -> None:
if self._has_reservation(mac):
messagebox.showinfo("Info", "Reservierung existiert bereits.")
return
self.res_mac_var.set(mac)
self.res_ip_var.set(ip)
self.res_name_var.set("")
self.add_or_update_reservation()
# -------------------- Helpers --------------------
def _format_duration(self, seconds: int) -> str:
if seconds <= 0:
return "0s"
m, s = divmod(seconds, 60)
h, m = divmod(m, 60)
if h > 0:
return f"{h}h {m}m"
if m > 0:
return f"{m}m {s}s"
return f"{s}s"
def _ensure_mandatory_ext_options(self, fail_on_missing: bool) -> None:
missing = []
for spec in self.ext_option_specs:
if spec.get("disabled"):
continue
if not spec.get("mandatory"):
continue
code = spec["code"]
val = self.ext_opt_values[code].get().strip()
if not val:
default_val = spec.get("default", "")
if default_val:
self.ext_opt_values[code].set(default_val)
val = default_val
if not val:
missing.append(f"Option {code} ({spec.get('label','')})")
else:
self.ext_opt_enabled[code].set(True)
if missing and fail_on_missing:
raise ValueError("Folgende Pflicht-Optionen fehlen: " + ", ".join(missing))