{% extends "tesm_core/base.html" %} {% from "tesm_core/macros.html" import field, checkbox, pill, empty_state, alert, search_box %} {% block page_title %}DHCP-Server{% endblock %} {% block page_sub %} Kea · {{ subnets | length }} Subnetze · {{ reservations | length }} Reservierungen {% endblock %} {% block content %} {# Der wichtigste Satz der Seite, wenn er zutrifft: was hier steht, ist nicht, womit der Dienst arbeitet. Lehnt Kea eine Datei ab, bleibt der alte Stand aktiv -- die Oberflaeche zeigte bisher trotzdem den neuen Wert, und man sucht den Fehler beim Client. #} {% if konfig_stand == 'abweichend' %}
{{ icon('alert', size=18) }}
Der Dienst arbeitet nicht mit dieser Konfiguration
Auf der Platte liegt ein anderer Stand als der hier gezeigte – entweder wurde die Datei zuletzt abgelehnt oder seither nichts geschrieben. Änderungen an Optionen und Reservierungen sind gespeichert, aber nicht wirksam. {% if current_user.has('dhcp_config.execute') %}
{% endif %}
{% endif %} {% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %} {% if not sysops_available %} {{ alert('Systemhelfer nicht verfügbar -- Konfiguration und Dienstzustand können hier nur angezeigt, nicht geschrieben werden.', 'warning') }} {% endif %}
Dienst {{ service.get('active', 'unbekannt') }} Autostart: {{ service.get('enabled', '?') }}
Programm {# Die Fassung statt nur "vorhanden": zwischen Kea 2.4 (Ubuntu 24.04) und 3.0 (26.04) liegen Aenderungen, und wer eine Konfiguration beurteilt, will wissen, wogegen. #} {{ service.get('version') or service.get('binary', '?') }} kea-dhcp4{% if service.get('version') %} · {{ service.get('binary', '?') }}{% endif %}
Reservierungen {{ reservations | length }} automatisch + manuell
Leases {{ leases | rejectattr('is_expired') | list | length }} gültig{% if leases | selectattr('is_expired') | list %}, {{ leases | selectattr('is_expired') | list | length }} abgelaufen{% endif %}
{# Dienst und Grundeinstellungen sind beides Kleinkram, den man selten anfasst -- nebeneinander statt untereinander, damit die Seite mit dem anfaengt, worum es geht: den Subnetzen und Optionen. #}
{% if current_user.has('dhcp_service.execute') %}
{{ icon('power', size=17) }} Dienst
Bewusst getrennt vom Speichern: eine abgelegte Konfiguration startet keinen Dienst.
{% for action, label, style, question in [ ('install', 'Kea installieren', '', 'Kea-DHCP-Server per apt installieren?'), ('start', 'Aktivieren und starten', 'primary', 'Dienst aktivieren und starten? Er verteilt danach IP-Adressen im Netz.'), ('stop', 'Stoppen und deaktivieren', 'danger', 'Dienst stoppen und deaktivieren? Clients erhalten danach keine Adressen mehr.')] %}
{% endfor %}
{% endif %} {% if current_user.has('dhcp_subnets.write') %}
{{ icon('sliders', size=17) }} Globale Einstellungen
{{ field('dhcp_domain', 'Domainname', value=values.dhcp_domain) }} {{ field('dhcp_lease_default', 'Lease-Dauer (s)', value=values.dhcp_lease_default, type='number') }} {{ field('dhcp_lease_max', 'Maximale Lease-Dauer (s)', value=values.dhcp_lease_max, type='number') }} {{ field('dhcp_output_path', 'Zielpfad der Konfiguration', value=values.dhcp_output_path, extra_class='field--full') }}
{% endif %}
{# -- Subnetze ---------------------------------------------------------- #}
{{ icon('network', size=17) }} Subnetze
Subnetz und Maske kommen vom laufenden System – eingegeben wird nur der Adressbereich.
{% if subnets %}
{% for subnet in subnets %} {% endfor %}
InterfaceBereichGatewayDNSAktiv
{{ subnet.interface }} {{ subnet.range_start }} – {{ subnet.range_end }} {{ subnet.gateway or '–' | safe }} {{ subnet.dns or '–' | safe }} {% if subnet.enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('pausiert', '') }}{% endif %} {% if current_user.has('dhcp_subnets.write') %}
{% endif %}
{% else %} {{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }} {% endif %} {% if current_user.has('dhcp_subnets.create') %}
{{ field('range_start', 'Von', required=True, placeholder='192.168.1.100') }} {{ field('range_end', 'Bis', required=True, placeholder='192.168.1.200') }} {{ field('gateway', 'Gateway', value=default_gateway) }} {{ field('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }} {{ field('comment', 'Kommentar') }}
{% endif %}
{# -- Optionen ---------------------------------------------------------- #} {# Zwei Makros, weil dieselbe Zeile zweimal vorkommt: einmal in der kurzen Liste dessen, was wirklich gesendet wird, und einmal im vollen Katalog. #} {% macro optionswert(option) %} {% if current_user.has('dhcp_options.write') %}
{# min-width:0 und flex:1 statt einer festen Mindestbreite: sonst passte das Feld nicht in die Spalte, die Zeile brach um, und der Haken stand allein darunter -- es sah aus wie ein Bedienfehler der Seite. #}
{% else %} {{ option.value or '–' | safe }} {% endif %} {% endmacro %} {% macro optionsloeschen(option) %} {% if current_user.has('dhcp_options.write') and not option.is_standard %}
{% endif %} {% endmacro %} {% set gesetzte = options | selectattr('value') | list %}
{{ icon('puzzle', size=17) }} Gesendete Optionen
{% if gesetzte %} {{ gesetzte | length }} von {{ options | length }} Optionen tragen einen Wert. Was leer ist, wird nicht gesendet. {% else %} Derzeit trägt keine Option einen Wert – es wird also nichts über die Grundangaben hinaus gesendet. {% endif %}
{% if gesetzte %}
{% for option in gesetzte %} {% endfor %}
CodeNameTyp Wert
{{ option.code }}
{{ option.name }}
{{ option.description }}
{{ option.type }} {{ optionswert(option) }} {{ optionsloeschen(option) }}
{% else %} {{ empty_state('Nichts gesetzt', 'Tragen Sie unten im Katalog einen Wert ein.', 'puzzle') }} {% endif %}
{# Der Katalog hat 57 Zeilen. Er stand vorher ganz oben, in einem eigenen Rollbereich innerhalb der scrollenden Seite -- und das Gesuchte ging darin unter. Jetzt liegt er hinter einem Klick, mit eigener Suche. #}
{{ icon('list', size=17) }} Optionskatalog
Alle {{ options | length }} bekannten Optionen. Standardoptionen sind nicht löschbar, ihr Wert lässt sich leeren.
{{ search_box('options-table', 'Option suchen ...') }}
{% for option in options %} {% endfor %}
CodeNameTyp Wert
{{ option.code }}
{{ option.name }}
{{ option.description }}
{{ option.type }} {{ optionswert(option) }} {{ optionsloeschen(option) }}
{% if current_user.has('dhcp_options.write') %}
{{ icon('plus', size=17) }} Eigene Option anlegen
Für alles jenseits des Katalogs – etwa eine URL, die der Client beim Start öffnet.
{{ field('code', 'Code', type='number', required=True, placeholder='225') }} {{ field('name', 'Name', required=True, placeholder='meine-option') }} {{ field('description', 'Beschreibung') }}
{# Der Wert einer Option gilt sonst für alle. Genau dafür gibt es ``dhcp_option_values.device_id``: ein eigener Wert je Gerät, der in dessen Reservierung geschrieben wird -- etwa eine Terminal-URL, die je Gerät anders lautet. Auswertbar war das von Anfang an, eingeben liess es sich nicht. #}
{{ icon('sliders', size=17) }} Option nur für ein Gerät
Der Wert landet in der Reservierung dieses Geräts und überschreibt dort den globalen Wert. Leeres Feld entfernt ihn wieder.
{{ field('value', 'Wert', extra_class='field--full', hint_text='Leer = Wert für dieses Gerät entfernen') }}
{% endif %} {# -- Was der Client dafür braucht -------------------------------------- #} {# Ein DHCP-Client bekommt nur, wonach er fragt. Gateway, DNS und Domain fragt er von sich aus; alles darüber hinaus -- und dazu gehört jede eigene Option -- muss er ausdrücklich anfordern. Ohne diese Zeilen sendet der Server brav, und der Client wirft es weg, ohne dass irgendwo etwas danebengeht. Genau deshalb steht das hier und nicht in einer Anleitung. #}
{{ icon('terminal', size=17) }} Konfiguration für Linux-Clients
Ein Client erhält nur die Optionen, die er anfordert. Diese Zeilen sorgen dafür – erzeugt aus dem, was dieser Server tatsächlich sendet.
{% set beispiel = client_konfig.get('networkd') %} {% if not beispiel or not beispiel.optionen %}
{{ alert('Dieser Server sendet derzeit keine Option, die ein Client eigens anfordern müsste. Sobald eine eigene Option einen Wert trägt, steht hier die passende Konfiguration.', 'info') }}
{% else %}

Betrifft {% for eintrag in beispiel.optionen %} {{ eintrag.code }} {{ eintrag.name }}{% if not loop.last %}, {% endif %} {% endfor %}. Welcher Abschnitt gilt, entscheidet die Distribution des Clients.

{% for stil, bezeichnung, pfad in client_stile %} {% set konfig = client_konfig.get(stil) %}
{{ bezeichnung }} {{ pfad }}
Inhalt der Datei
{{ konfig.inhalt }}
Danach: {{ konfig.nachher }}
{% if konfig.auslesen.einfach %} {# Ohne diese Zeile kann niemand pruefen, ob es angekommen ist -- und ein RMM-Agent braucht genau sie. #}
Auslesen auf dem Client: {{ konfig.auslesen.einfach }}
{% if konfig.auslesen.roh %}
systemd legt fremde Optionen unter ihrer Nummer und hexkodiert ab – den Klartext schreibt ein kleiner Übersetzer, den der SSH-Push mitinstalliert (/run/tesm-dhcp-options.env, nach jeder Lease erneuert). Direkt aus der Lease: {{ konfig.auslesen.roh }}
{% endif %} {% endif %} {% if current_user.has('servicing_command.execute') %}
Oder direkt schreiben lassen – über SSH, mit den hinterlegten Zugangsdaten. Jeder Client bekommt einen eigenen Auftrag; die Ausgabe steht unter Wartung.
{% for reservation in reservations if reservation.device_id %} {% endfor %}
{% endif %}
{% endfor %} {% endif %}
{# -- Reservierungen und Leases ----------------------------------------- #}
{{ icon('credit-card', size=17) }} Reservierungen
Automatisch aus dem Gerätebestand; bei MAC-Kollision gewinnt der Gerätebestand. Nur Adressen aus eingerichteten Bereichen.
{% if skipped_reservations %} {# Nicht verschweigen, sondern benennen: vorher standen diese Einträge in der Liste, obwohl Kea sie nie ausgeliefert hat. #}
{{ alert(skipped_reservations | length ~ ' Gerät(e) liegen in keinem eingerichteten Bereich und bekommen keine Reservierung. Legen Sie den passenden Bereich an, wenn sie eine bekommen sollen.', 'warning') }}
Welche?
    {% for entry in skipped_reservations %}
  • {{ entry.ip }} – {{ entry.name }} ({{ entry.source }})
  • {% endfor %}
{% endif %}
{% for reservation in reservations %} {% endfor %}
MACIPHostnameZustandQuelle Eigene Optionen
{{ reservation.mac }} {{ reservation.ip }} {{ reservation.hostname }} {{ pill(reservation.state | replace('_', ' '), reservation.state_style) }} {% if reservation.state == 'andere_adresse' and reservation.lease %}
jetzt {{ reservation.lease.address }}
{% elif reservation.state == 'aktiv' and reservation.lease %}
{{ reservation.lease.remaining_text }}
{% endif %}
{{ pill(reservation.source, 'accent' if reservation.source == 'auto' else '') }} {% set eigene = device_option_values.get(reservation.device_id) if reservation.device_id else none %} {% if eigene %} {% for wert in eigene %}
{{ wert.code }} {{ wert.name }} = {{ wert.value }}
{% endfor %} {% else %} – {% endif %}
{% if reservation.source == 'manuell' and current_user.has('dhcp_reservations.delete') %}
{% endif %}
{% if current_user.has('dhcp_reservations.create') %}
{{ field('mac', 'MAC', required=True, placeholder='aa:bb:cc:dd:ee:ff') }} {{ field('ip', 'IP', required=True) }} {{ field('hostname', 'Hostname') }} {{ field('comment', 'Kommentar') }}
{% endif %}
{{ icon('clock', size=17) }} Leases
Direkt aus {{ lease_file }} gelesen – je Adresse gilt der letzte Eintrag. {% if current_user.has('dhcp_lease_adopt.read') and current_user.has('devices.create') %} Eine dynamische Lease lässt sich als Client übernehmen; sie bekommt damit eine feste Adresse und wird überwacht. {% endif %}
{% if leases and service.get('active') != 'active' %} {# Sonst sieht man alte Einträge und hält sie für den aktüllen Stand: ohne laufenden Dienst wird keine Lease erneuert, und die Datei veraltet still. #}
{{ alert('Der DHCP-Dienst läuft nicht (' ~ service.get('active', 'unbekannt') ~ '). Es wird keine Lease erneuert -- die Liste zeigt den letzten Stand der Datei, nicht die aktülle Lage. Der Grund steht im Kea-Protokoll.', 'warning') }}
{% endif %} {% if leases %}
{% for lease in leases %} {% endfor %}
IPMACHostnameArtZustand Läuft ab
{{ lease.address }} {{ lease.mac }} {{ lease.hostname or '–' | safe }} {% if lease.address in reserved_ips %}{{ pill('Reservierung', 'accent') }} {% else %}{{ pill('dynamisch', '') }}{% endif %} {% if lease.is_expired %}{{ pill('abgelaufen', 'warning') }} {% elif lease.is_active %}{{ pill('gültig', 'online') }} {% else %}{{ pill('inaktiv', '') }}{% endif %} {{ lease.expires_text }}
{{ lease.remaining_text }}
{# Übernehmen gibt es nur für dynamische Leases: was reserviert ist, gehört schon zu einem gepflegten Datensatz. Und nur, wenn beide Rechte stehen -- hier entsteht ein Client. #} {% if lease.address not in reserved_ips and current_user.has('dhcp_lease_adopt.read') and current_user.has('devices.create') %} {% if lease.mac in bekannte_macs %} bereits Client {% else %}
{# Kein Geisterknopf mehr: die Uebernahme ist die eine Handlung in dieser Tabelle, und sie war als "btn--ghost" kaum von der Zeile zu unterscheiden. #}
{% endif %} {% endif %}
{% else %} {{ empty_state('Keine Leases', 'Es sind keine vergebenen Adressen bekannt.', 'clock') }} {% endif %}
{# -- Vorschau ---------------------------------------------------------- #}
{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration
Vorschau – so würde die Datei geschrieben
{{ preview }}
{% if current_user.has('dhcp_config.execute') %} {% endif %}
{% endblock %}