Compare commits

..
3 Commits
10 changed files with 252 additions and 9 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.8.27"
version = "2.8.30"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+93 -1
View File
@@ -20,7 +20,7 @@ from tesm_core.auth.service import client_ip
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from ..services import kea
from ..services import inventory, kea
from ..permissions import DHCP_PAGE
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
@@ -73,6 +73,12 @@ def overview():
# Zustand aus den laufenden Leases -- wie im Windows-DHCP.
reservations = kea.annotate_reservations(reservations, leases)
reserved_ips = {str(entry["ip"]) for entry in reservations}
# Wozu es schon einen Client gibt, ist nicht mehr zu uebernehmen -- auch
# wenn dessen Adresse (noch) keine Reservierung hat.
bekannte_macs = {
inventory.normalize_mac(str(zeile["mac"]))
for zeile in Database.all(conn, "SELECT mac FROM devices WHERE deleted_at IS NULL")
}
return render_template(
"tesm/dhcp.html",
@@ -90,6 +96,7 @@ def overview():
manual_reservations=Database.all(conn, "SELECT * FROM dhcp_reservations ORDER BY ip"),
leases=leases,
reserved_ips=reserved_ips,
bekannte_macs=bekannte_macs,
networks=networks,
default_gateway=kea.default_gateway(sysops) if sysops else "",
service=kea.service_state(sysops) if sysops else {},
@@ -447,6 +454,91 @@ def add_reservation():
return _write_and_redirect()
@bp.post("/lease/adopt")
# Zwei Rechte, mit Absicht: die Uebernahme ist eine eigene Funktion, das
# Ergebnis aber ein Client. Wer keine Clients anlegen darf, legt auch hier
# keine an -- sonst waere diese Schaltflaeche eine Hintertuer an der
# Geraeteverwaltung vorbei.
@permission_required("dhcp_lease_adopt.execute", "devices.create")
def adopt_lease():
"""Macht aus einer dynamisch vergebenen Adresse einen ueberwachten Client.
Die Angaben kommen **nicht** aus dem Formular, sondern aus der Lease-Datei:
geschickt wird nur die MAC, alles Weitere liest TESM frisch nach. Damit
heisst "Uebernahme" auch Uebernahme -- und eine Lease, die zwischen
Seitenaufbau und Klick abgelaufen oder umgezogen ist, faellt auf, statt
stillschweigend einen falschen Datensatz anzulegen.
"""
extension = core()
conn = db()
sysops = extension.sysops
gesucht = inventory.normalize_mac(request.form.get("mac") or "")
leases = kea.read_leases(sysops) if sysops else []
lease = next((eintrag for eintrag in leases if eintrag.mac == gesucht), None)
if lease is None:
flash(
"Zu dieser MAC liegt keine Lease mehr vor. Die Liste war nicht mehr aktuell.",
"warning",
)
return redirect(url_for("dhcp.overview"))
# Nur dynamische. Eine Adresse, die schon reserviert ist, gehoert bereits
# zu einem gepflegten Datensatz -- da gibt es nichts zu uebernehmen.
networks = kea.detect_networks(sysops) if sysops else []
reserviert = {
str(eintrag["ip"])
for eintrag in kea.collect_reservations(
conn, only_networks=kea.configured_networks(conn, networks)
)
}
if lease.address in reserviert:
flash(
f"{lease.address} ist bereits reserviert – diese Lease ist nicht dynamisch.",
"warning",
)
return redirect(url_for("dhcp.overview"))
# Der Hostname aus der Lease ist der Name, den das Geraet selbst nennt.
# Fehlt er, steht die Adresse da: ein Name wie "Gerät 7" sagt weniger.
name = (lease.hostname or "").strip() or f"Client {lease.address}"
try:
with transaction():
device_id = inventory.create_device(
conn,
name=name,
mac=lease.mac,
ip=lease.address,
switch_id=None,
port="",
credential_id=None,
ssh_port=None,
# Ohne Switch und Port gibt es keinen PoE-Neustart. Mit
# eingeschaltetem Automatismus schriebe die Ueberwachung bei
# jedem Ausfall einen Fehlschlag ins Protokoll, den niemand
# abstellen kann. Einschalten kann man ihn, sobald der Port
# eingetragen ist.
auto_restart=False,
actor=current_user.username,
)
except inventory.ValidationError as exc:
flash(str(exc), "danger")
return redirect(url_for("dhcp.overview"))
_audit(
"device.created",
name,
f"aus dynamischer Lease {lease.address} ({lease.mac}) übernommen",
severity="notice",
)
flash(
f"{name} wurde als Client übernommen. Switch, Port und Zugangsdaten fehlen noch – "
"ohne sie gibt es keinen PoE-Neustart und keine Wartung. Die feste Adresse entsteht "
"beim nächsten Schreiben der DHCP-Konfiguration.",
"success",
)
return redirect(url_for("devices.detail", device_id=device_id))
@bp.post("/reservation/<int:reservation_id>/delete")
@permission_required("dhcp_reservations.delete")
def delete_reservation(reservation_id: int):
+33 -1
View File
@@ -264,6 +264,31 @@ DHCP_SERVICE = Resource(
action_hints={A.EXECUTE: "Dienstzustand ändern."},
)
#: Eine dynamische Lease in einen Client verwandeln.
#:
#: Eigene Zeile, weil es eine eigene Entscheidung ist: hier wird aus einer
#: Beobachtung ("dieses Geraet hat sich eine Adresse geholt") ein gepflegter
#: Datensatz, den die Ueberwachung ab sofort anpingt und der beim naechsten
#: Schreiben der Konfiguration eine feste Adresse bekommt. Wer Leases sehen
#: darf, soll deshalb nicht automatisch welche uebernehmen duerfen.
#:
#: **Und es reicht allein nicht.** Die Uebernahme legt einen Client an, also
#: verlangt die Route zusaetzlich "Clients: Anlegen". Sonst waere dies eine
#: Hintertuer an der Geraeteverwaltung vorbei -- dieselbe Wirkung, anderes
#: Formular. Beide Haken zusammen, oder die Schaltflaeche erscheint nicht.
DHCP_LEASE_ADOPT = Resource(
key="dhcp_lease_adopt",
parent="dhcp_subnets",
label="DHCP: Lease als Client übernehmen",
actions=(A.READ, A.EXECUTE),
hint="Aus einer dynamisch vergebenen Adresse einen überwachten Client machen",
action_hints={
A.READ: "Die Schaltfläche an dynamischen Leases sehen.",
A.EXECUTE: "Client aus der Lease anlegen. Verlangt zusätzlich "
"'Clients: Anlegen' – es entsteht ein Gerätedatensatz.",
},
)
#: Die DHCP-Seite zeigt alle Abschnitte; jeder Abschnitt haengt an seiner
#: eigenen Zeile. Geoeffnet wird sie, wer einen davon sehen darf.
DHCP_PAGE: tuple[str, ...] = (
@@ -380,7 +405,14 @@ NETWORK_AREA = Area(
key="network",
label="Netzwerkdienste",
icon="network",
resources=(DHCP_SUBNETS, DHCP_OPTIONS, DHCP_RESERVATIONS, DHCP_CONFIG, DHCP_SERVICE),
resources=(
DHCP_SUBNETS,
DHCP_OPTIONS,
DHCP_RESERVATIONS,
DHCP_LEASE_ADOPT,
DHCP_CONFIG,
DHCP_SERVICE,
),
)
FILES_AREA = Area(
+30 -1
View File
@@ -377,6 +377,10 @@
<div class="card__title">{{ icon('clock', size=17) }} Leases</div>
<div class="card__sub">
Direkt aus {{ lease_file }} gelesen &ndash; je Adresse gilt der letzte Eintrag.
{% if current_user.has('dhcp_lease_adopt.read') and current_user.has('devices.create') %}
Eine <strong>dynamische</strong> Lease lässt sich als Client übernehmen; sie bekommt
damit eine feste Adresse und wird überwacht.
{% endif %}
</div>
</div>
</div>
@@ -394,7 +398,7 @@
<div class="table-wrap" style="max-height:420px;overflow:auto">
<table class="table table--compact">
<thead><tr><th>IP</th><th>MAC</th><th>Hostname</th><th>Art</th><th>Zustand</th>
<th>Läuft ab</th></tr></thead>
<th>Läuft ab</th><th></th></tr></thead>
<tbody>
{% for lease in leases %}
<tr>
@@ -413,6 +417,31 @@
<td class="fs-sm">{{ lease.expires_text }}
<div class="fs-xs faint">{{ lease.remaining_text }}</div>
</td>
<td class="nowrap">
{# Ü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 %}
<span class="faint fs-xs" title="Zu dieser MAC gibt es bereits einen Client">
bereits Client
</span>
{% else %}
<form method="post" action="{{ url_for('dhcp.adopt_lease') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="mac" value="{{ lease.mac }}">
<button class="btn btn--sm btn--ghost" type="submit"
data-confirm="{{ lease.hostname or lease.address }} als überwachten Client anlegen? Switch, Port und Zugangsdaten tragen Sie danach nach."
data-confirm-title="Als Client übernehmen"
data-confirm-ok="Übernehmen">
{{ icon('plus', size=14) }} Als Client
</button>
</form>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
+25 -2
View File
@@ -418,9 +418,32 @@ info "Dateien abgeglichen (Instanzdaten unter data/ bleiben unangetastet)"
# -- venv --------------------------------------------------------------------
step "Python-Umgebung"
if [[ ! -x "$ROOT/venv/bin/python3" ]]; then
# Ein venv haengt an der **Minor-Fassung**, mit der er gebaut wurde: die Pakete
# liegen unter lib/python3.X. Wechselt die Systemfassung -- ein Release-Upgrade
# von Ubuntu bringt genau das, 24.04 hat 3.12 und 26.04 hat 3.14 --, dann ist
# venv/bin/python3 weiter ausfuehrbar: der Verweis zeigt auf /usr/bin/python3,
# also auf den **neuen** Interpreter. Der venv sieht heil aus und ist es nicht.
# Er findet danach kein einziges Paket mehr, nicht einmal pip.
#
# Gemessen auf dem Testserver, mit verschobenem lib-Verzeichnis:
# [[ -x venv/bin/python3 ]] -> wahr
# venv/bin/python3 -m pip --version -> "No module named pip"
#
# Die alte Pruefung fragte nur nach der Ausfuehrbarkeit. Sie haette den toten
# venv behalten und waere eine Zeile spaeter an "python3 -m pip" gescheitert --
# mitten in einer Aktualisierung, auf einem Server, dessen Dienste ohnehin
# schon stehen. Gefragt wird deshalb, was gebraucht wird: laesst sich in
# diesem venv pip benutzen?
venv_traegt() {
[[ -x "$ROOT/venv/bin/python3" ]] || return 1
"$ROOT/venv/bin/python3" -c "import pip" >/dev/null 2>&1
}
if ! venv_traegt; then
# Ohne --clear: ein erneutes "venv" ergaenzt das fehlende lib-Verzeichnis und
# holt pip zurueck; die Abhaengigkeiten kommen gleich darunter ohnehin neu.
# Auch das ist gemessen und nicht angenommen.
python3 -m venv "$ROOT/venv"
info "venv erzeugt"
info "Python-Umgebung aufgebaut ($(python3 -V 2>&1))"
fi
# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten
# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht.
+16
View File
@@ -150,6 +150,22 @@ def main(argv: list[str] | None = None) -> int:
globals().update(AMBER="", DIM="", BOLD="", RESET="")
root = Path(args.root)
# Gibt es diese Installation ueberhaupt noch? Wenn nicht, ist hier nichts
# zu melden -- auch kein Fehler.
#
# Der Hook unter /etc/update-motd.d bleibt liegen, wenn jemand eine
# Installation von Hand entfernt; einen Rueckbau bringt TESM nicht mit.
# Vorher druckte jeder dieser Hooks trotzdem seinen vollen Banner und
# darunter "Datenbank nicht lesbar". Auf dem Testserver standen so
# vierzehn Bloecke fuer vierzehn laengst geloeschte Probeinstallationen
# vor jeder Anmeldung -- und der echte Banner ging darin unter.
#
# Eine entfernte Installation ist keine kaputte. Eine Datenbank, die da
# ist und sich nicht lesen laesst, dagegen schon: die meldet sich weiter.
if not (root / "data" / "app.db").is_file():
return 0
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
# trotzdem ein echtes Terminal -- deshalb immer Farbe.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.8.16"
version = "2.8.17"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -42,6 +42,8 @@ def _config_from_settings(extension: Any, conn: Any) -> webserver.WebConfig:
],
)
return webserver.WebConfig(
# Die neue Schreibweise, sobald der Webserver sie kennt.
http2_directive=webserver.nginx_kann_http2(),
app_key=extension.config.site_key,
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
static_root=str(
@@ -58,8 +58,14 @@ class WebConfig:
#: ``listen ... ssl http2;`` und weisen die neue Direktive mit
#: "unknown directive" ab -- der ganze Webserver startet dann nicht mehr.
#: Auf POETEST (nginx 1.24) genau so passiert. Vorgabe ist deshalb die alte
#: Schreibweise: sie funktioniert ueberall und erzeugt auf neuen Versionen
#: nur eine Warnung.
#: Schreibweise: sie funktioniert ueberall.
#:
#: Wer die Konfiguration erzeugt, fragt vorher ``nginx_kann_http2()`` --
#: dann steht auf einem neuen Webserver auch die neue Schreibweise. Das ist
#: nicht nur Kosmetik gegen eine Warnung: nginx hat die alte Form als
#: veraltet erklaert, und was veraltet ist, faellt irgendwann weg. Ein
#: Release-Upgrade von Ubuntu ist genau der Moment, in dem so etwas
#: unbemerkt naeherrueckt (24.04 hat nginx 1.24, 26.04 hat 1.28).
http2_directive: bool = False
@property
@@ -89,6 +95,47 @@ class WebConfig:
return "" if self.https_port == 443 else f":{self.https_port}"
#: Ab dieser Fassung kennt nginx ``http2 on;``.
HTTP2_DIREKTIVE_AB = (1, 25, 1)
def fassung_aus_ausgabe(text: str) -> tuple[int, ...]:
"""``nginx version: nginx/1.28.3 (Ubuntu)`` -> ``(1, 28, 3)``.
Getrennt vom Aufruf, damit sich die Entscheidung pruefen laesst, ohne
einen Webserver zu installieren. Was nicht ins Muster passt, ergibt ein
leeres Tupel -- und damit die Schreibweise, die ueberall funktioniert.
"""
treffer = re.search(r"nginx/(\d+)\.(\d+)\.(\d+)", text or "")
return tuple(int(teil) for teil in treffer.groups()) if treffer else ()
def nginx_version() -> tuple[int, ...]:
"""Die Fassung des installierten nginx, sonst ein leeres Tupel.
``nginx -v`` schreibt nach stderr und braucht keine Rechte. Faellt die
Antwort aus -- kein nginx, kein Ausfuehrungsrecht, fremdes Format --, gibt
es hier nichts zu raten.
"""
import shutil
import subprocess
pfad = shutil.which("nginx") or "/usr/sbin/nginx"
try:
fertig = subprocess.run(
[pfad, "-v"], capture_output=True, text=True, timeout=5, check=False
)
except (OSError, subprocess.SubprocessError):
return ()
return fassung_aus_ausgabe(fertig.stderr + fertig.stdout)
def nginx_kann_http2() -> bool:
"""Versteht der installierte nginx ``http2 on;``?"""
fassung = nginx_version()
return bool(fassung) and fassung >= HTTP2_DIREKTIVE_AB
def validate_domain(value: str) -> str:
text = (value or "").strip().lower().rstrip(".")
if not text:
@@ -204,6 +204,8 @@ def _web_config() -> webserver.WebConfig:
],
)
return webserver.WebConfig(
# Die neue Schreibweise, sobald der Webserver sie kennt.
http2_directive=webserver.nginx_kann_http2(),
app_key=extension.config.site_key,
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
static_root=str(extension.registry.get("static_root", extension.config.base_dir / "static")),