Compare commits

..
4 Commits
11 changed files with 283 additions and 11 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.31"
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.18"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -266,6 +266,35 @@ def _bitte_bestaetigen(permission: str) -> Any:
return redirect(url_for("auth.reauth"))
def _heikel(permissions: tuple[str, ...], mode: str) -> str:
"""Welches Recht verlangt hier eine frische Bestaetigung? Sonst leer.
Bei ``mode="all"`` ist die Antwort einfach: jedes genannte Recht wird
gebraucht, also zaehlt jedes heikle darunter.
Bei ``mode="any"`` nicht. Diese Seiten nennen mehrere **unabhaengige**
Wege hinein, und welcher genommen wird, entscheidet das Konto. Der
Protokollverlauf etwa nennt "logs_history.read" und "logs_history.export":
lesen ist harmlos, mitnehmen nicht. Gezaehlt wurde aber ueber alle
genannten Rechte -- und so verlangte das blosse **Ansehen** des Verlaufs
nach fuenfzehn Minuten eine Passworteingabe, mit dem Hinweis, man greife
auf einen Datenexport zu. Das stimmte nicht, und wer es dreimal liest,
glaubt die Meldung beim vierten Mal auch dann nicht, wenn sie zutrifft.
Gefragt wird deshalb: haette dieses Konto die Seite auch ohne das heikle
Recht geoeffnet? Dann gibt es nichts zu bestaetigen. Die Ausfuhr selbst
haengt an ihrer eigenen Route, und die nennt nur ein Recht.
"""
heikle = [p for p in permissions if _verlangt_wiederanmeldung(p)]
if not heikle:
return ""
if mode == "any":
harmlos = [p for p in permissions if p not in heikle]
if any(current_user.has(p) for p in harmlos):
return ""
return heikle[0]
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
"""Erzwingt Rechte serverseitig.
@@ -289,9 +318,9 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
# Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach,
# sie per Mail zu verschicken nicht.
heikel = [p for p in permissions if _verlangt_wiederanmeldung(p)]
heikel = _heikel(permissions, mode)
if heikel and not _wiederanmeldung_frisch():
return _bitte_bestaetigen(heikel[0])
return _bitte_bestaetigen(heikel)
return view(*args, **kwargs)
wrapper._required_permissions = permissions # type: ignore[attr-defined]
@@ -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")),