Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f59ba42e55 | ||
|
|
b1275b2c71 | ||
|
|
1d8f92fd2d | ||
|
|
5324174f90 | ||
|
|
d69d177995 | ||
|
|
f481a91773 | ||
|
|
970f45cdf7 | ||
|
|
c9ceb871b6 | ||
|
|
37c508297f | ||
|
|
13bf07fecf | ||
|
|
849b821501 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm"
|
||||
version = "2.8.32"
|
||||
version = "2.8.43"
|
||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -11,6 +11,7 @@ einen Dienst starten.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
@@ -104,10 +105,31 @@ def overview():
|
||||
preview=kea.config_to_text(config),
|
||||
warnings=warnings,
|
||||
lease_file=kea.LEASE_FILE,
|
||||
# Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen --
|
||||
# je Stil einmal, damit die Seite umschalten kann, ohne nachzuladen.
|
||||
client_stile=kea.CLIENT_STILE,
|
||||
client_konfig={
|
||||
stil: kea.client_konfiguration(conn, stil) for stil, _, _ in kea.CLIENT_STILE
|
||||
},
|
||||
sysops_available=bool(sysops and sysops.available),
|
||||
# "gespeichert" heisst nicht "wirksam": lehnt Kea die Datei ab, bleibt
|
||||
# der Dienst auf dem alten Stand, waehrend die Seite den neuen Wert
|
||||
# zeigt. Deshalb wird hier verglichen, was auf der Platte liegt.
|
||||
konfig_stand=_konfig_stand(config),
|
||||
)
|
||||
|
||||
|
||||
def _konfig_stand(gewuenscht: dict[str, Any]) -> str:
|
||||
"""``aktuell``, ``abweichend`` oder ``unbekannt``."""
|
||||
sysops = core().sysops
|
||||
if sysops is None or not sysops.available:
|
||||
return "unbekannt"
|
||||
vorhanden = kea.laufende_konfiguration(sysops)
|
||||
if vorhanden is None:
|
||||
return "unbekannt"
|
||||
return "aktuell" if vorhanden == gewuenscht else "abweichend"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Einstellungen und Subnetze
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -141,7 +163,13 @@ def add_subnet():
|
||||
start = (request.form.get("range_start") or "").strip()
|
||||
end = (request.form.get("range_end") or "").strip()
|
||||
|
||||
problem = kea.validate_subnet(networks, interface, start, end)
|
||||
problem = kea.validate_subnet(
|
||||
networks,
|
||||
interface,
|
||||
start,
|
||||
end,
|
||||
vorhandene=Database.all(conn, "SELECT * FROM dhcp_subnets"),
|
||||
)
|
||||
if problem:
|
||||
flash(problem, "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
@@ -176,7 +204,14 @@ def update_subnet(subnet_id: int):
|
||||
interface = (request.form.get("interface") or "").strip()
|
||||
start = (request.form.get("range_start") or "").strip()
|
||||
end = (request.form.get("range_end") or "").strip()
|
||||
problem = kea.validate_subnet(networks, interface, start, end)
|
||||
problem = kea.validate_subnet(
|
||||
networks,
|
||||
interface,
|
||||
start,
|
||||
end,
|
||||
vorhandene=Database.all(conn, "SELECT * FROM dhcp_subnets"),
|
||||
eigene_id=subnet_id,
|
||||
)
|
||||
if problem:
|
||||
flash(problem, "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
@@ -601,12 +636,114 @@ def _write_and_redirect():
|
||||
for warning in result.get("warnings", []):
|
||||
flash(warning, "warning")
|
||||
if not result["ok"] and result.get("message"):
|
||||
flash(result["message"], "warning")
|
||||
# Rot, nicht orange: die Aenderung steht in der Datenbank und auf der
|
||||
# Seite, wirkt aber nicht. Das ist der Zustand, in dem man stundenlang
|
||||
# sucht, warum ein Client die Option nicht bekommt.
|
||||
flash(
|
||||
result["message"] + " Die Änderung ist gespeichert, aber noch nicht wirksam.",
|
||||
"danger",
|
||||
)
|
||||
elif result["ok"]:
|
||||
flash(result["message"], "info")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
|
||||
@bp.post("/client-config/push")
|
||||
# Verteilt wird ueber dieselbe Maschinerie wie ein Massenbefehl: Zugangsdaten
|
||||
# aus dem Datensatz, ein Auftrag je Geraet, Ausgabe zum Mitlesen. Deshalb
|
||||
# haengt es auch an demselben Recht -- wer Befehle auf Clients ausfuehren darf,
|
||||
# darf auch diese Datei schreiben; es ist dieselbe Wirkung.
|
||||
@permission_required("servicing_command.execute")
|
||||
def push_client_config():
|
||||
"""Schreibt die Client-Konfiguration per SSH auf ausgewaehlte Clients.
|
||||
|
||||
Der Inhalt ist fuer alle gleich -- angefordert wird, was der Server sendet,
|
||||
und das haengt nicht am einzelnen Geraet. Die **Werte** kommen weiterhin
|
||||
vom Server; hier geht es nur darum, dass der Client danach fragt.
|
||||
"""
|
||||
from ..services import maintenance as wartung
|
||||
|
||||
extension = core()
|
||||
conn = db()
|
||||
stil = (request.form.get("stil") or "").strip()
|
||||
if stil not in dict((s[0], s) for s in kea.CLIENT_STILE):
|
||||
flash("Unbekannter Client-Stil.", "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
konfig = kea.client_konfiguration(conn, stil)
|
||||
if not konfig["optionen"]:
|
||||
flash(
|
||||
"Es gibt nichts zu verteilen: dieser Server sendet derzeit keine Option, "
|
||||
"die ein Client eigens anfordern müsste.",
|
||||
"info",
|
||||
)
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
device_ids = [int(v) for v in request.form.getlist("device_ids") if v.isdigit()]
|
||||
if not device_ids:
|
||||
flash("Es wurde kein Gerät ausgewählt.", "warning")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
# Ueber base64 statt ueber ein Here-Dokument: der Inhalt enthaelt
|
||||
# Anfuehrungszeichen, Rauten und Zeilenumbrueche, und er geht durch SSH,
|
||||
# eine Shell und moeglicherweise sudo. Was kodiert reist, kommt an.
|
||||
inhalt = base64.b64encode(konfig["inhalt"].encode("utf-8")).decode("ascii")
|
||||
verzeichnis = str(konfig["pfad"]).rsplit("/", 1)[0]
|
||||
befehl = (
|
||||
f"{{sudo}} mkdir -p {verzeichnis} && "
|
||||
f"echo {inhalt} | base64 -d | {{sudo}} tee {konfig['pfad']} >/dev/null && "
|
||||
f"{{sudo}} sh -c '{konfig['nachher']}' || true"
|
||||
)
|
||||
|
||||
gestartet = 0
|
||||
for device_id in device_ids:
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None or not device["credential_id"]:
|
||||
continue
|
||||
try:
|
||||
username, password = inventory.credential_secret(
|
||||
conn, extension.keystore, int(device["credential_id"])
|
||||
)
|
||||
except inventory.ValidationError as exc:
|
||||
flash(f"{device['name']}: {exc}", "danger")
|
||||
continue
|
||||
with transaction():
|
||||
auftrag = wartung.create_job(
|
||||
conn,
|
||||
kind=wartung.JOB_KIND_COMMAND,
|
||||
subject=str(device["name"]),
|
||||
actor=current_user.username,
|
||||
message="Wartet auf Start",
|
||||
)
|
||||
wartung.start_background(
|
||||
wartung.run_command,
|
||||
database=extension.database,
|
||||
job_id=auftrag.id,
|
||||
device=dict(device),
|
||||
username=username,
|
||||
password=password,
|
||||
befehl=befehl,
|
||||
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
|
||||
)
|
||||
gestartet += 1
|
||||
|
||||
if gestartet:
|
||||
_audit(
|
||||
"dhcp.client_config_pushed",
|
||||
f"{gestartet} Client(s)",
|
||||
f"Stil {stil}, Datei {konfig['pfad']}",
|
||||
severity="notice",
|
||||
)
|
||||
flash(
|
||||
f"{gestartet} Auftrag/Aufträge gestartet. Der Verlauf und die Ausgabe stehen "
|
||||
"unter Wartung.",
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
flash("Kein Gerät mit hinterlegten Zugangsdaten ausgewählt.", "warning")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
|
||||
@bp.post("/write")
|
||||
@permission_required("dhcp_config.execute")
|
||||
def write_config():
|
||||
|
||||
@@ -19,7 +19,11 @@ from tesm_core.auth.models import (
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
|
||||
from tesm_core.licensing_client import (
|
||||
ABFRAGE_WARNSCHWELLE_TAGE,
|
||||
LICENSED_FEATURES,
|
||||
LicenseError,
|
||||
)
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
@@ -50,8 +54,29 @@ def overview():
|
||||
manager = extension.license
|
||||
state = manager.reload(conn) if manager else None
|
||||
pending = manager._pending(conn) if manager else {} # noqa: SLF001 - eigenes Paket
|
||||
# Der Befehl, den ein Agent auf dieser Maschine ausfuehrt. Zusammengesetzt
|
||||
# aus dem, was hier wirklich installiert ist -- der Shim traegt den Namen
|
||||
# der Instanz, und der Dienstbenutzer auch. Abgeschrieben aus der Doku
|
||||
# waere er auf einer benannten Instanz schlicht falsch.
|
||||
werkzeug = f"/usr/local/bin/{extension.config.site_key}-admin"
|
||||
# Der Dienstbenutzer traegt den Namen der **Anwendung**, nicht den der
|
||||
# Instanz: install.sh setzt SERVICE_USER="$APP". Auf "tesm-test" heisst er
|
||||
# also "tesm" -- und auf dem Lizenzserver "tesm-license". Aus dem
|
||||
# Instanznamen ableiten waere genau dort falsch.
|
||||
dienstbenutzer = extension.config.app_key
|
||||
rmm_befehl = f"sudo -u {dienstbenutzer} {werkzeug} license"
|
||||
return render_template(
|
||||
"tesm/license.html",
|
||||
rmm_befehl=rmm_befehl,
|
||||
rmm_befehl_tage=f"{rmm_befehl} --days",
|
||||
rmm_warnschwelle=ABFRAGE_WARNSCHWELLE_TAGE,
|
||||
rmm_powershell=(
|
||||
f'$j = (& {rmm_befehl} 2>$null) | ConvertFrom-Json' + chr(10)
|
||||
+ 'if (-not $j.valid) { exit 2 }' + chr(10)
|
||||
+ 'if ($j.lifetime) { exit 0 }' + chr(10)
|
||||
+ f'if ($j.days_left -le {ABFRAGE_WARNSCHWELLE_TAGE}) ' + '{ exit 1 }' + chr(10)
|
||||
+ 'exit 0'
|
||||
),
|
||||
state=state,
|
||||
pending=pending,
|
||||
server_certificate=extension.settings.get(conn, "license_server_ca") or "",
|
||||
|
||||
@@ -110,6 +110,36 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_license(args: argparse.Namespace) -> int:
|
||||
"""Die Lizenz als Auskunft fuer ein Ueberwachungswerkzeug.
|
||||
|
||||
Gedacht fuer einen RMM-Agenten, der auf dieser Maschine laeuft: kein Netz,
|
||||
kein Token, keine Anmeldung. Die Antwort steht auf **stdout**, alles andere
|
||||
(Protokollzeilen des Starts) auf stderr -- ein ``| ConvertFrom-Json`` soll
|
||||
nicht daran scheitern, dass eine Migration etwas erzaehlt hat.
|
||||
|
||||
Der Ausstiegscode traegt die Aussage: 0 heil, 1 laeuft bald ab, 2
|
||||
ungueltig. Das ist die Sprache, in der ein RMM Alarm schlaegt -- Text
|
||||
auszuwerten waere der Umweg.
|
||||
"""
|
||||
from tesm_core.licensing_client import abfrage_ausstiegscode, zustand_als_auskunft
|
||||
|
||||
app, extension = _app_and_core()
|
||||
if extension.license is None:
|
||||
print("{}")
|
||||
return 2
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
auskunft = zustand_als_auskunft(extension.license.reload(conn))
|
||||
if getattr(args, "days", False):
|
||||
# Eine Zahl, sonst nichts -- fuer Werkzeuge, die keinen JSON-Parser
|
||||
# mitbringen. Unbefristet hat keine Zahl und sagt das auch.
|
||||
tage = auskunft.get("days_left")
|
||||
print("unbefristet" if auskunft.get("lifetime") else ("" if tage is None else int(tage)))
|
||||
else:
|
||||
print(json.dumps(auskunft, ensure_ascii=False, sort_keys=True))
|
||||
return abfrage_ausstiegscode(auskunft)
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
@@ -552,6 +582,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
status.add_argument("--json", action="store_true")
|
||||
status.set_defaults(func=cmd_status)
|
||||
|
||||
lizenz = sub.add_parser(
|
||||
"license", help="Lizenzzustand als JSON ausgeben (für Überwachungswerkzeuge)"
|
||||
)
|
||||
lizenz.add_argument(
|
||||
"--days", action="store_true", help="Nur die Restlaufzeit in Tagen ausgeben"
|
||||
)
|
||||
lizenz.set_defaults(func=cmd_license)
|
||||
|
||||
house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufräumen")
|
||||
house.set_defaults(func=cmd_housekeeping)
|
||||
|
||||
|
||||
@@ -186,9 +186,20 @@ def matching_network(
|
||||
|
||||
|
||||
def validate_subnet(
|
||||
networks: Iterable[InterfaceNetwork], interface: str, range_start: str, range_end: str
|
||||
networks: Iterable[InterfaceNetwork],
|
||||
interface: str,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
*,
|
||||
vorhandene: Iterable[Any] = (),
|
||||
eigene_id: int | None = None,
|
||||
) -> str:
|
||||
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung."""
|
||||
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung.
|
||||
|
||||
``vorhandene`` sind die schon eingetragenen Bereiche. Ueberschneidet sich
|
||||
der neue mit einem davon, entsteht eine Konfiguration, die Kea als Ganzes
|
||||
ablehnt -- besser hier sagen als spaeter beim Schreiben.
|
||||
"""
|
||||
if not interface:
|
||||
return "Es ist kein Interface ausgewählt."
|
||||
try:
|
||||
@@ -203,9 +214,41 @@ def validate_subnet(
|
||||
f"Der Bereich {range_start}-{range_end} passt in kein Netz von {interface}. "
|
||||
"Subnetz und Maske werden vom laufenden System gelesen."
|
||||
)
|
||||
for zeile in vorhandene:
|
||||
if eigene_id is not None and int(zeile["id"]) == eigene_id:
|
||||
continue
|
||||
if str(zeile["interface"]) != interface:
|
||||
continue
|
||||
try:
|
||||
anfang = int(ipaddress.ip_address(str(zeile["range_start"])))
|
||||
ende = int(ipaddress.ip_address(str(zeile["range_end"])))
|
||||
except ValueError:
|
||||
continue
|
||||
if int(start) <= ende and int(end) >= anfang:
|
||||
return (
|
||||
f"Der Bereich überschneidet sich mit {zeile['range_start']}-"
|
||||
f"{zeile['range_end']} auf {interface}. Erweitern Sie den vorhandenen "
|
||||
"Bereich, statt einen zweiten anzulegen."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def laufende_konfiguration(sysops: SysOps) -> dict[str, Any] | None:
|
||||
"""Die Konfiguration, die tatsaechlich auf der Platte liegt.
|
||||
|
||||
``None`` heisst: nicht lesbar (kein Helfer, Datei fehlt, kein JSON) -- das
|
||||
ist etwas anderes als "weicht ab" und wird auch anders angezeigt.
|
||||
"""
|
||||
result = sysops.read_privileged_file(DEFAULT_CONFIG_PATH)
|
||||
if not result.ok or not (result.stdout or "").strip():
|
||||
return None
|
||||
try:
|
||||
geladen = json.loads(result.stdout)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return geladen if isinstance(geladen, dict) else None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Reservierungen
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -393,8 +436,16 @@ def render_config(
|
||||
return entries
|
||||
|
||||
subnet_entries: list[dict[str, Any]] = []
|
||||
# Ein Netz, ein Eintrag. Kea lehnt die **ganze** Konfiguration ab, sobald
|
||||
# derselbe Praefix zweimal darin steht ("subnet with the prefix of
|
||||
# '192.168.82.0/24' already exists") -- und dann laeuft der Dienst
|
||||
# unbemerkt mit dem alten Stand weiter. Zwei Zeilen im selben Netz sind
|
||||
# aber nichts Unsinniges: es sind zwei Bereiche. Sie werden deshalb zu
|
||||
# einem Subnetz mit mehreren Pools zusammengefasst.
|
||||
nach_netz: dict[str, dict[str, Any]] = {}
|
||||
belegte_bereiche: dict[str, list[tuple[int, int]]] = {}
|
||||
interfaces: set[str] = set()
|
||||
for index, subnet in enumerate(subnets, start=1):
|
||||
for subnet in subnets:
|
||||
interface = str(subnet["interface"])
|
||||
match = matching_network(
|
||||
networks, interface, str(subnet["range_start"]), str(subnet["range_end"])
|
||||
@@ -433,15 +484,40 @@ def render_config(
|
||||
entry["option-data"] = option_data(overrides)
|
||||
subnet_reservations.append(entry)
|
||||
|
||||
subnet_entries.append(
|
||||
{
|
||||
"id": index,
|
||||
"subnet": str(network),
|
||||
"pools": [{"pool": f"{subnet['range_start']} - {subnet['range_end']}"}],
|
||||
schluessel = str(network)
|
||||
eintrag = nach_netz.get(schluessel)
|
||||
if eintrag is None:
|
||||
eintrag = {
|
||||
# Die Nummer zaehlt die Eintraege, nicht die Zeilen der
|
||||
# Tabelle -- sonst haette ein uebersprungenes Subnetz eine
|
||||
# Luecke hinterlassen, und Kea verlangt eindeutige IDs.
|
||||
"id": len(subnet_entries) + 1,
|
||||
"subnet": schluessel,
|
||||
"pools": [],
|
||||
**({"option-data": subnet_options} if subnet_options else {}),
|
||||
"reservations": subnet_reservations,
|
||||
}
|
||||
nach_netz[schluessel] = eintrag
|
||||
belegte_bereiche[schluessel] = []
|
||||
subnet_entries.append(eintrag)
|
||||
elif subnet_options and subnet_options != eintrag.get("option-data", []):
|
||||
warnings.append(
|
||||
f"Für {schluessel} sind mehrere Bereiche mit unterschiedlichem Gateway oder "
|
||||
"DNS eingetragen. Ein Netz kann nur eines haben | es gilt der erste Eintrag."
|
||||
)
|
||||
|
||||
# Auch zwei Pools, die sich ueberschneiden, bringen Kea zu Fall.
|
||||
von, bis = int(ipaddress.ip_address(str(subnet["range_start"]))), int(
|
||||
ipaddress.ip_address(str(subnet["range_end"]))
|
||||
)
|
||||
if any(von <= ende and bis >= anfang for anfang, ende in belegte_bereiche[schluessel]):
|
||||
warnings.append(
|
||||
f"Der Bereich {subnet['range_start']}-{subnet['range_end']} überschneidet sich "
|
||||
"mit einem bereits eingetragenen und wurde übersprungen."
|
||||
)
|
||||
continue
|
||||
belegte_bereiche[schluessel].append((von, bis))
|
||||
eintrag["pools"].append({"pool": f"{subnet['range_start']} - {subnet['range_end']}"})
|
||||
|
||||
if not subnet_entries:
|
||||
warnings.append("Es ist kein gültiges Subnetz konfiguriert | Kea würde nicht starten.")
|
||||
@@ -653,9 +729,157 @@ def read_leases(sysops: SysOps, *, path: str = LEASE_FILE) -> list[Lease]:
|
||||
return sorted(latest.values(), key=lambda entry: tuple(int(p) for p in entry.address.split(".") if p.isdigit()))
|
||||
|
||||
|
||||
#: Optionen, die jeder DHCP-Client von sich aus erfragt. Sie in die
|
||||
#: Client-Konfiguration zu schreiben waere Ballast -- und wer Ballast liest,
|
||||
#: liest irgendwann gar nicht mehr.
|
||||
IMMER_ANGEFRAGT = frozenset({1, 3, 6, 12, 15, 28, 42, 51, 54, 58, 59, 119, 121})
|
||||
|
||||
#: Die Stile, in denen eine Client-Konfiguration ausgegeben wird.
|
||||
#:
|
||||
#: Drei, weil es auf den Zielsystemen drei gibt, und die Wahl nicht am
|
||||
#: Geschmack haengt, sondern daran, was die Distribution mitbringt:
|
||||
#:
|
||||
#: * **networkd** -- Ubuntu-Server 22.04 bis 26.04 (netplan erzeugt die
|
||||
#: .network-Dateien), auch auf Debian verbreitet. Angeforderte Fremdoptionen
|
||||
#: landen als ``OPTION_<code>`` in der Lease unter /run/systemd/netif/leases.
|
||||
#: * **dhcpcd** -- Debian 12/13, Raspberry Pi OS, ab Ubuntu 24.04 verfuegbar.
|
||||
#: * **dhclient** -- Debian 11 bis 13 und Ubuntu 22.04, dort wo isc-dhcp-client
|
||||
#: noch installiert ist.
|
||||
CLIENT_STILE: tuple[tuple[str, str, str], ...] = (
|
||||
("networkd", "systemd-networkd", "/etc/systemd/network/10-tesm.network.d/50-tesm.conf"),
|
||||
("dhcpcd", "dhcpcd", "/etc/dhcpcd.conf"),
|
||||
("dhclient", "dhclient (isc-dhcp-client)", "/etc/dhcp/dhclient.conf"),
|
||||
)
|
||||
|
||||
#: Wie ein Typ aus dem Optionskatalog in den jeweiligen Clients heisst.
|
||||
_DHCPCD_TYPEN = {
|
||||
"string": "string",
|
||||
"ipv4-address": "ipaddress",
|
||||
"uint8": "byte",
|
||||
"uint16": "uint16",
|
||||
"uint32": "uint32",
|
||||
"boolean": "byte",
|
||||
}
|
||||
_DHCLIENT_TYPEN = {
|
||||
"string": "text",
|
||||
"ipv4-address": "ip-address",
|
||||
"uint8": "unsigned integer 8",
|
||||
"uint16": "unsigned integer 16",
|
||||
"uint32": "unsigned integer 32",
|
||||
"boolean": "boolean",
|
||||
}
|
||||
|
||||
|
||||
def gesendete_optionen(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
"""Die Optionen, die dieser Server tatsaechlich ausliefert.
|
||||
|
||||
Gemeint ist: wofuer irgendwo ein Wert gesetzt ist -- global oder an einem
|
||||
einzelnen Geraet. Was im Katalog steht, aber leer ist, wird nicht gesendet
|
||||
und muss vom Client auch nicht angefordert werden.
|
||||
"""
|
||||
return Database.all(
|
||||
conn,
|
||||
"""
|
||||
SELECT DISTINCT o.code, o.name, o.type, o.description, o.is_standard
|
||||
FROM dhcp_options o
|
||||
JOIN dhcp_option_values v ON v.option_id = o.id
|
||||
WHERE TRIM(COALESCE(v.value, '')) <> ''
|
||||
ORDER BY o.code
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]:
|
||||
"""Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen.
|
||||
|
||||
**Warum es das braucht.** Ein DHCP-Client bekommt nur, wonach er fragt.
|
||||
Standardoptionen (Gateway, DNS, Domain) fragt er von sich aus; alles
|
||||
darueber hinaus -- und dazu gehoert jede eigene Option -- muss er
|
||||
ausdruecklich anfordern. Ohne diese Zeilen sendet der Server brav und der
|
||||
Client wirft es weg, ohne dass irgendwo etwas danebengeht.
|
||||
|
||||
Rueckgabe: ``{"pfad", "inhalt", "nachher", "optionen"}`` -- Datei, Inhalt,
|
||||
der Befehl zum Uebernehmen und die Optionen, um die es geht.
|
||||
"""
|
||||
optionen = [
|
||||
eintrag
|
||||
for eintrag in gesendete_optionen(conn)
|
||||
if int(eintrag["code"]) not in IMMER_ANGEFRAGT
|
||||
]
|
||||
codes = [int(eintrag["code"]) for eintrag in optionen]
|
||||
pfad = dict((s[0], s[2]) for s in CLIENT_STILE).get(stil, "")
|
||||
|
||||
if stil == "networkd":
|
||||
zeilen = [
|
||||
"# Von TESM erzeugt. Fordert die Optionen an, die dieser Server sendet --",
|
||||
"# ohne diese Zeilen verwirft der Client sie stillschweigend.",
|
||||
"#",
|
||||
"# Der Dateiname muss zur .network-Datei der Schnittstelle passen:",
|
||||
"# netplan legt sie unter /run/systemd/network/10-netplan-<if>.network ab,",
|
||||
"# der Zusatz gehoert also nach",
|
||||
"# /etc/systemd/network/10-netplan-<if>.network.d/50-tesm.conf.",
|
||||
"#",
|
||||
"# Die Werte stehen danach in /run/systemd/netif/leases/<ifindex>",
|
||||
"# als OPTION_<code>=...",
|
||||
"[DHCPv4]",
|
||||
]
|
||||
zeilen.append("RequestOptions=" + " ".join(str(code) for code in codes))
|
||||
nachher = "networkctl reload && networkctl reconfigure <schnittstelle>"
|
||||
elif stil == "dhcpcd":
|
||||
zeilen = [
|
||||
"# Von TESM erzeugt -- an das Ende von /etc/dhcpcd.conf.",
|
||||
"# 'define' macht die Option bekannt, 'option' fordert sie an.",
|
||||
"# Danach steht der Wert in der Lease (/var/lib/dhcpcd/<if>.lease)",
|
||||
"# und laesst sich mit 'dhcpcd -U <if>' als Variable auslesen.",
|
||||
]
|
||||
for eintrag in optionen:
|
||||
typ = _DHCPCD_TYPEN.get(str(eintrag["type"]), "string")
|
||||
zeilen.append(f"define {eintrag['code']} {typ} {eintrag['name']}")
|
||||
for eintrag in optionen:
|
||||
zeilen.append(f"option {eintrag['name']}")
|
||||
nachher = "systemctl restart dhcpcd"
|
||||
else:
|
||||
zeilen = [
|
||||
"# Von TESM erzeugt -- an das Ende von /etc/dhcp/dhclient.conf.",
|
||||
"# Die Werte stehen danach in /var/lib/dhcp/dhclient.leases,",
|
||||
"# und in Hooks unter $new_<name>.",
|
||||
]
|
||||
for eintrag in optionen:
|
||||
typ = _DHCLIENT_TYPEN.get(str(eintrag["type"]), "text")
|
||||
zeilen.append(f"option {eintrag['name']} code {eintrag['code']} = {typ};")
|
||||
if optionen:
|
||||
zeilen.append(
|
||||
"also request " + ", ".join(str(eintrag["name"]) for eintrag in optionen) + ";"
|
||||
)
|
||||
nachher = "systemctl restart networking || dhclient -r && dhclient"
|
||||
|
||||
return {
|
||||
"pfad": pfad,
|
||||
"inhalt": chr(10).join(zeilen) + chr(10),
|
||||
"nachher": nachher,
|
||||
"optionen": optionen,
|
||||
}
|
||||
|
||||
|
||||
def service_state(sysops: SysOps) -> dict[str, str]:
|
||||
state = sysops.service_state(SERVICE_UNIT)
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
state["binary"] = "vorhanden" if shutil.which("kea-dhcp4") else "fehlt"
|
||||
pfad = shutil.which("kea-dhcp4")
|
||||
state["binary"] = "vorhanden" if pfad else "fehlt"
|
||||
# Die Fassung gehoert auf die Seite: zwischen Kea 2.4 (Ubuntu 24.04) und
|
||||
# 3.0 (26.04) liegen Aenderungen, und wer eine Konfiguration beurteilt,
|
||||
# will nicht erst auf der Kommandozeile nachsehen muessen, wogegen.
|
||||
state["version"] = ""
|
||||
if pfad:
|
||||
try:
|
||||
fertig = subprocess.run(
|
||||
[pfad, "-V"], capture_output=True, text=True, timeout=5, check=False
|
||||
)
|
||||
# ``kea-dhcp4 -V`` antwortet mit "3.0.3 (3.0.3 (tarball))" -- auf
|
||||
# einer Kachel steht davon die Nummer, nicht die Herkunft.
|
||||
state["version"] = (fertig.stdout or "").strip().split()[0].strip()
|
||||
except (OSError, subprocess.SubprocessError, IndexError):
|
||||
state["version"] = ""
|
||||
return state
|
||||
@@ -36,14 +36,13 @@
|
||||
{% if section.tiles %}
|
||||
<details class="card" data-tile-section="{{ section.key }}"
|
||||
data-persist="dash-{{ section.key }}" {% if section.key != 'disabled' %}open{% endif %}>
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<summary class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ pill(section.tiles | length, section.kind if section.kind != 'muted' else '') }}
|
||||
{{ section.label }}
|
||||
</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
<div class="card__body">
|
||||
<div class="grid grid--tiles">
|
||||
|
||||
@@ -7,6 +7,29 @@
|
||||
{% 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' %}
|
||||
<div class="alert alert--danger">
|
||||
{{ icon('alert', size=18) }}
|
||||
<div class="alert__body flex-1">
|
||||
<div class="alert__title">Der Dienst arbeitet nicht mit dieser Konfiguration</div>
|
||||
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 <strong>nicht wirksam</strong>.
|
||||
{% if current_user.has('dhcp_config.execute') %}
|
||||
<form method="post" action="{{ url_for('dhcp.write_config') }}" class="mt-2" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--primary" type="submit" data-busy-label="Schreibt ...">
|
||||
Jetzt schreiben und Fehler anzeigen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% 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
|
||||
@@ -21,8 +44,13 @@
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Programm</span>
|
||||
<span class="stat__value fs-lg">{{ service.get('binary', '?') }}</span>
|
||||
<span class="stat__hint">kea-dhcp4</span>
|
||||
{# 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. #}
|
||||
<span class="stat__value fs-lg">{{ service.get('version') or service.get('binary', '?') }}</span>
|
||||
<span class="stat__hint">
|
||||
kea-dhcp4{% if service.get('version') %} · {{ service.get('binary', '?') }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Reservierungen</span>
|
||||
@@ -39,6 +67,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# 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. #}
|
||||
<div class="grid grid--2">
|
||||
{% if current_user.has('dhcp_service.execute') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
@@ -66,6 +98,31 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('dhcp_subnets.write') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('dhcp.save_settings') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ 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') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Speichern und schreiben</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{# -- Subnetze ---------------------------------------------------------- #}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
@@ -134,45 +191,111 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# -- Globale Einstellungen --------------------------------------------- #}
|
||||
{% if current_user.has('dhcp_subnets.write') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('dhcp.save_settings') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ 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') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Speichern und schreiben</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% 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') %}
|
||||
<form method="post" action="{{ url_for('dhcp.set_option_value') }}"
|
||||
class="row row--tight" style="flex-wrap:nowrap" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="option_id" value="{{ option.id }}">
|
||||
{# 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. #}
|
||||
<input class="input input--mono" type="text" name="value"
|
||||
value="{{ option.value or '' }}" style="min-width:0;flex:1"
|
||||
placeholder="leer = nicht senden">
|
||||
<button class="btn btn--sm" type="submit" title="Wert übernehmen">
|
||||
{{ icon('check', size=14) }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="mono fs-sm">{{ option.value or '–' | safe }}</span>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro optionsloeschen(option) %}
|
||||
{% if current_user.has('dhcp_options.write') and not option.is_standard %}
|
||||
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Eigene Option {{ option.name }} löschen?"
|
||||
data-confirm-title="Option löschen" data-confirm-ok="Löschen">
|
||||
{{ icon('trash', size=14) }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% set gesetzte = options | selectattr('value') | list %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('puzzle', size=17) }} DHCP-Optionen</div>
|
||||
<div class="card__title">{{ icon('puzzle', size=17) }} Gesendete Optionen</div>
|
||||
<div class="card__sub">
|
||||
{{ options | length }} Optionen · Standardoptionen sind nicht löschbar, ihr Wert
|
||||
lässt sich leeren
|
||||
{% if gesetzte %}
|
||||
<strong>{{ gesetzte | length }}</strong> 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{{ search_box('options-table', 'Option suchen ...') }}
|
||||
</div>
|
||||
<div class="table-wrap" style="max-height:420px;overflow:auto">
|
||||
|
||||
{% if gesetzte %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr>
|
||||
<th class="numeric">Code</th><th>Name</th><th>Typ</th>
|
||||
<th style="width:42%">Wert</th><th class="actions"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for option in gesetzte %}
|
||||
<tr>
|
||||
<td class="numeric fs-sm">{{ option.code }}</td>
|
||||
<td class="fs-sm">
|
||||
<div class="mono">{{ option.name }}</div>
|
||||
<div class="fs-xs faint">{{ option.description }}</div>
|
||||
</td>
|
||||
<td class="fs-xs mono">{{ option.type }}</td>
|
||||
<td>{{ optionswert(option) }}</td>
|
||||
<td class="actions">{{ optionsloeschen(option) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Nichts gesetzt', 'Tragen Sie unten im Katalog einen Wert ein.', 'puzzle') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# 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. #}
|
||||
<details class="card" data-persist="dhcp-katalog">
|
||||
<summary class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('list', size=17) }} Optionskatalog</div>
|
||||
<div class="card__sub">
|
||||
Alle {{ options | length }} bekannten Optionen. Standardoptionen sind nicht löschbar,
|
||||
ihr Wert lässt sich leeren.
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="card__body pt-0">{{ search_box('options-table', 'Option suchen ...') }}</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact" id="options-table">
|
||||
<thead><tr><th class="numeric">Code</th><th>Name</th><th>Typ</th><th>Wert</th><th class="actions"></th></tr></thead>
|
||||
<thead><tr>
|
||||
<th class="numeric">Code</th><th>Name</th><th>Typ</th>
|
||||
<th style="width:42%">Wert</th><th class="actions"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for option in options %}
|
||||
<tr data-filter-text="{{ option.code }} {{ option.name }} {{ option.description }}">
|
||||
@@ -182,43 +305,30 @@
|
||||
<div class="fs-xs faint">{{ option.description }}</div>
|
||||
</td>
|
||||
<td class="fs-xs mono">{{ option.type }}</td>
|
||||
<td>
|
||||
{% if current_user.has('dhcp_options.write') %}
|
||||
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="option_id" value="{{ option.id }}">
|
||||
<input class="input input--mono" type="text" name="value" value="{{ option.value or '' }}"
|
||||
style="min-width:180px" placeholder="leer = nicht senden">
|
||||
<button class="btn btn--sm" type="submit">{{ icon('check', size=14) }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="mono fs-sm">{{ option.value or '–' | safe }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if current_user.has('dhcp_options.write') and not option.is_standard %}
|
||||
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Eigene Option {{ option.name }} löschen?"
|
||||
data-confirm-title="Option löschen" data-confirm-ok="Löschen">
|
||||
{{ icon('trash', size=14) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ optionswert(option) }}</td>
|
||||
<td class="actions">{{ optionsloeschen(option) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if current_user.has('dhcp_options.write') %}
|
||||
</details>
|
||||
|
||||
{% if current_user.has('dhcp_options.write') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('plus', size=17) }} Eigene Option anlegen</div>
|
||||
<div class="card__sub">
|
||||
Für alles jenseits des Katalogs – etwa eine URL, die der Client beim Start öffnet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('dhcp.add_custom_option') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ field('code', 'Code', type='number', required=True) }}
|
||||
{{ field('code', 'Code', type='number', required=True, placeholder='225') }}
|
||||
{{ field('name', 'Name', required=True, placeholder='meine-option') }}
|
||||
<label class="field">
|
||||
<span class="field__label">Typ</span>
|
||||
@@ -231,13 +341,15 @@
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn" type="submit">Eigene Option anlegen</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{# 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. #}
|
||||
<div class="card__header" style="border-top:1px solid var(--border-subtle)">
|
||||
{# 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. #}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('sliders', size=17) }} Option nur für ein Gerät</div>
|
||||
<div class="card__sub">
|
||||
@@ -276,6 +388,93 @@
|
||||
<button class="btn" type="submit">Für dieses Gerät speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% 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.
|
||||
#}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Konfiguration für Linux-Clients</div>
|
||||
<div class="card__sub">
|
||||
Ein Client erhält nur die Optionen, die er anfordert. Diese Zeilen sorgen dafür –
|
||||
erzeugt aus dem, was dieser Server tatsächlich sendet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set beispiel = client_konfig.get('networkd') %}
|
||||
{% if not beispiel or not beispiel.optionen %}
|
||||
<div class="card__body">
|
||||
{{ 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') }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card__body pt-0">
|
||||
<p class="fs-sm faint">
|
||||
Betrifft
|
||||
{% for eintrag in beispiel.optionen %}
|
||||
<code>{{ eintrag.code }} {{ eintrag.name }}</code>{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}.
|
||||
Welcher Abschnitt gilt, entscheidet die Distribution des Clients.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% for stil, bezeichnung, pfad in client_stile %}
|
||||
{% set konfig = client_konfig.get(stil) %}
|
||||
<details class="card__body pt-0">
|
||||
<summary class="row row--tight">
|
||||
<strong class="fs-sm">{{ bezeichnung }}</strong>
|
||||
<span class="fs-xs faint mono">{{ pfad }}</span>
|
||||
</summary>
|
||||
<div class="stack stack--tight mt-2">
|
||||
<div class="row row--tight">
|
||||
<span class="fs-xs faint flex-1">Inhalt der Datei</span>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-copy="#konfig-{{ stil }}"
|
||||
data-copy-message="Konfiguration kopiert.">
|
||||
{{ icon('copy', size=14) }} Kopieren
|
||||
</button>
|
||||
</div>
|
||||
<pre class="code-block code-block--wrap" id="konfig-{{ stil }}">{{ konfig.inhalt }}</pre>
|
||||
<div class="fs-xs faint">Danach: <code>{{ konfig.nachher }}</code></div>
|
||||
|
||||
{% if current_user.has('servicing_command.execute') %}
|
||||
<form method="post" action="{{ url_for('dhcp.push_client_config') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="stil" value="{{ stil }}">
|
||||
<div class="divider"></div>
|
||||
<div class="fs-xs faint mb-2">
|
||||
Oder direkt schreiben lassen – über SSH, mit den hinterlegten Zugangsdaten.
|
||||
Jeder Client bekommt einen eigenen Auftrag; die Ausgabe steht unter Wartung.
|
||||
</div>
|
||||
<div class="row row--tight" style="flex-wrap:wrap">
|
||||
{% for reservation in reservations if reservation.device_id %}
|
||||
<label class="check">
|
||||
<input type="checkbox" name="device_ids" value="{{ reservation.device_id }}">
|
||||
<span>{{ reservation.name or reservation.hostname }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn--sm btn--primary" type="submit"
|
||||
data-confirm="Die Konfiguration für {{ bezeichnung }} auf die ausgewählten Clients schreiben ({{ pfad }})? Der DHCP-Client wird dort anschließend neu gestartet."
|
||||
data-confirm-title="Konfiguration verteilen" data-confirm-ok="Verteilen">
|
||||
{{ icon('upload', size=15) }} An ausgewählte Clients senden
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
@@ -432,7 +631,10 @@
|
||||
<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"
|
||||
{# Kein Geisterknopf mehr: die Uebernahme ist die eine
|
||||
Handlung in dieser Tabelle, und sie war als
|
||||
"btn--ghost" kaum von der Zeile zu unterscheiden. #}
|
||||
<button class="btn btn--sm btn--primary" 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">
|
||||
@@ -455,12 +657,11 @@
|
||||
|
||||
{# -- Vorschau ---------------------------------------------------------- #}
|
||||
<details class="card" data-persist="dhcp-preview">
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<summary class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration</div>
|
||||
<div class="card__sub">Vorschau – so würde die Datei geschrieben</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
<pre class="log-view">{{ preview }}</pre>
|
||||
{% if current_user.has('dhcp_config.execute') %}
|
||||
|
||||
@@ -249,6 +249,39 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Fuer ein Ueberwachungswerkzeug, das auf dieser Maschine laeuft. Bewusst
|
||||
kein HTTP-Endpunkt mit Token: der Agent sitzt ohnehin hier, und was ohne
|
||||
Netz auskommt, kann auch niemand aus dem Netz abfragen. #}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Abfrage durch ein RMM</div>
|
||||
<div class="card__sub">
|
||||
Ein Agent auf dieser Maschine liest die Restlaufzeit ohne Anmeldung und ohne Netz.
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-copy="#rmm-json"
|
||||
data-copy-message="Befehl kopiert.">{{ icon('copy', size=14) }} Kopieren</button>
|
||||
</div>
|
||||
<div class="card__body stack stack--tight">
|
||||
<pre class="code-block code-block--wrap" id="rmm-json">{{ rmm_befehl }}</pre>
|
||||
<p class="fs-xs faint">
|
||||
Antwortet mit einer Zeile JSON auf <strong>stdout</strong>
|
||||
(<code>valid</code>, <code>days_left</code>, <code>expires_at</code>,
|
||||
<code>lifetime</code>, <code>modules</code>). Der <strong>Ausstiegscode</strong> trägt
|
||||
die Aussage: <strong>0</strong> in Ordnung, <strong>1</strong> läuft in
|
||||
{{ rmm_warnschwelle }} Tagen oder weniger ab, <strong>2</strong> ungültig oder nicht
|
||||
vorhanden. Meldungen des Starts gehen nach stderr und stören das Auswerten nicht.
|
||||
</p>
|
||||
<div class="divider"></div>
|
||||
<div class="fs-xs faint">Nur die Restlaufzeit, ohne JSON-Auswertung:</div>
|
||||
<pre class="code-block code-block--wrap" id="rmm-tage">{{ rmm_befehl_tage }}</pre>
|
||||
<div class="divider"></div>
|
||||
<div class="fs-xs faint">Beispiel für einen Agenten mit PowerShell:</div>
|
||||
<pre class="code-block code-block--wrap">{{ rmm_powershell }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
|
||||
+33
-43
@@ -26,6 +26,23 @@
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
|
||||
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
|
||||
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
|
||||
# nicht erzeugt, warnt **jede neu gestartete Shell**:
|
||||
#
|
||||
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
|
||||
#
|
||||
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
|
||||
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
|
||||
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
|
||||
# klingt, liest sich schlecht.
|
||||
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
|
||||
export LC_ALL=C.UTF-8 LANG=C.UTF-8
|
||||
else
|
||||
export LC_ALL=C LANG=C
|
||||
fi
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
|
||||
@@ -418,49 +435,16 @@ info "Dateien abgeglichen (Instanzdaten unter data/ bleiben unangetastet)"
|
||||
|
||||
# -- venv --------------------------------------------------------------------
|
||||
step "Python-Umgebung"
|
||||
# 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 "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.
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel
|
||||
PAKETE=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
|
||||
# Die Serverhaelfte des Lizenzprotokolls -- ausstellen, erneuern, antworten --
|
||||
# gehoert ausschliesslich zum Lizenzserver. Ein Client verifiziert Lizenzen und
|
||||
# stellt Anfragen; den Code zum Ausstellen soll er nicht einmal mitbringen.
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
[[ -d "$ROOT/packages/tesm-licensing-server" ]] ||
|
||||
fail "packages/tesm-licensing-server fehlt -- ohne sie kann der Lizenzserver nichts ausstellen"
|
||||
PAKETE+=("$ROOT/packages/tesm-licensing-server")
|
||||
fi
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet "${PAKETE[@]}" "$ROOT/apps/$APP" gunicorn
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
"$ROOT/venv/bin/python3" -m pip install --quiet flask-sock
|
||||
fi
|
||||
# Aufbauen, pruefen und reparieren steht in genau einem Programm -- und
|
||||
# dasselbe laeuft als ExecStartPre vor jedem Dienststart. Zwei getrennte
|
||||
# Paketlisten waeren zwei Listen, die auseinanderlaufen: die Reparatur baute
|
||||
# dann eine Umgebung, die nicht mehr der Installation entspricht.
|
||||
# Ueber "bash" und nicht als Programm: Dateien im Baum tragen kein
|
||||
# Ausfuehrungsrecht -- git kennt auf einer Windows-Buildmaschine keines, und
|
||||
# install.sh, tesm-helper und tesm-motd werden aus demselben Grund ebenso
|
||||
# aufgerufen oder mit ausdruecklichem Modus installiert. Ohne das brach die
|
||||
# Aktualisierung mit "Permission denied" ab (und rollte zurueck).
|
||||
bash "$ROOT/deploy/tesm-venv" "$ROOT" "$APP" --immer
|
||||
info "Abhaengigkeiten installiert"
|
||||
|
||||
# -- Statische Dateien fuer nginx --------------------------------------------
|
||||
@@ -490,6 +474,12 @@ install_if_changed() {
|
||||
install_if_changed "$ROOT/deploy/tesm-helper" /usr/local/lib/tesm/tesm-helper 0755 && \
|
||||
info "Helfer aktualisiert" || info "Helfer unveraendert"
|
||||
|
||||
# Derselbe Baustein, der oben die Umgebung gebaut hat: die Dienste rufen ihn
|
||||
# vor jedem Start auf und reparieren damit, was ein Release-Upgrade des
|
||||
# Betriebssystems ihnen unter den Fuessen weggezogen hat.
|
||||
install_if_changed "$ROOT/deploy/tesm-venv" /usr/local/lib/tesm/tesm-venv 0755 >/dev/null \
|
||||
&& info "Umgebungspfleger aktualisiert" || true
|
||||
|
||||
# visudo -c prueft die Datei, bevor sie aktiv wird -- eine kaputte sudoers-Datei
|
||||
# waere sonst ein Totalausfall der Administration.
|
||||
TMP_SUDO="$(mktemp)"
|
||||
|
||||
@@ -15,11 +15,23 @@ Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
|
||||
Environment=TESM_LICENSE_WEB_PROCESS=1
|
||||
Environment=TESM_LICENSE_COOKIE_SECURE=1
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
|
||||
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
|
||||
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
|
||||
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
|
||||
# sonst baut sie die Umgebung neu, einmal je Systemstart.
|
||||
#
|
||||
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
|
||||
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
|
||||
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm-license tesm-license
|
||||
ExecStart=/srv/tesm-license/venv/bin/gunicorn \
|
||||
--workers 1 --worker-class gthread --threads 8 \
|
||||
--bind 127.0.0.1:5001 \
|
||||
--access-logfile - --error-logfile - \
|
||||
tesm_license.wsgi:app
|
||||
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
|
||||
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
|
||||
TimeoutStartSec=900
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
|
||||
@@ -12,7 +12,19 @@ Environment=TESM_BASE_DIR=/srv/tesm
|
||||
Environment=TESM_DATA_DIR=/srv/tesm/data
|
||||
Environment=TESM_LOG_DIR=/var/log/tesm
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
|
||||
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
|
||||
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
|
||||
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
|
||||
# sonst baut sie die Umgebung neu, einmal je Systemstart.
|
||||
#
|
||||
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
|
||||
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
|
||||
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
|
||||
ExecStart=/srv/tesm/venv/bin/tesm-monitor
|
||||
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
|
||||
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
|
||||
TimeoutStartSec=900
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
|
||||
@@ -15,11 +15,23 @@ Environment=TESM_LOG_DIR=/var/log/tesm
|
||||
Environment=TESM_WEB_PROCESS=1
|
||||
Environment=TESM_COOKIE_SECURE=1
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
|
||||
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
|
||||
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
|
||||
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
|
||||
# sonst baut sie die Umgebung neu, einmal je Systemstart.
|
||||
#
|
||||
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
|
||||
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
|
||||
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
|
||||
ExecStart=/srv/tesm/venv/bin/gunicorn \
|
||||
--workers 1 --worker-class gthread --threads 16 --timeout 0 \
|
||||
--bind 127.0.0.1:5000 \
|
||||
--access-logfile - --error-logfile - \
|
||||
tesm.wsgi:app
|
||||
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
|
||||
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
|
||||
TimeoutStartSec=900
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
|
||||
@@ -100,6 +100,10 @@ check_readable_path() {
|
||||
case "$1" in
|
||||
/var/log/kea/kea-dhcp4.log) ;;
|
||||
/var/lib/kea/kea-leases4.csv) ;;
|
||||
# Die eigene Kea-Konfiguration: 0640 root:_kea, also fuer den
|
||||
# Dienstbenutzer nicht lesbar. Die Anwendung braucht sie, um zu sehen, ob
|
||||
# der Dienst ueberhaupt mit dem laeuft, was sie geschrieben hat.
|
||||
/etc/kea/kea-dhcp4.conf) ;;
|
||||
/etc/netplan/90-*.yaml)
|
||||
check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;;
|
||||
/etc/nginx/sites-available/*)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Die Python-Umgebung einer Installation -- aufbauen, pruefen, reparieren.
|
||||
#
|
||||
# tesm-venv /srv/tesm tesm # reparieren, falls noetig (Dienststart)
|
||||
# tesm-venv /srv/tesm tesm --immer # neu bestuecken (Installation)
|
||||
# tesm-venv /srv/tesm tesm --pruefen # nur melden: 0 heil, 1 kaputt
|
||||
#
|
||||
# **Warum es das gibt.** Ein venv haengt an der Minor-Fassung von Python: die
|
||||
# Pakete liegen unter lib/python3.X. Ein Release-Upgrade des Betriebssystems
|
||||
# tauscht diese Fassung aus (Ubuntu 24.04 bringt 3.12, 26.04 bringt 3.14) --
|
||||
# und danach zeigt venv/bin/python3 weiter auf /usr/bin/python3, also auf den
|
||||
# **neuen** Interpreter. Der venv ist ausfuehrbar und trotzdem tot: er findet
|
||||
# kein einziges Paket mehr, nicht einmal pip. Jeder Dienst scheitert mit
|
||||
# "ModuleNotFoundError: No module named 'gunicorn'", nginx meldet 502.
|
||||
#
|
||||
# Real passiert, auf beiden Testservern, am 16.09.2026.
|
||||
#
|
||||
# Dieses Programm laeuft deshalb als ExecStartPre vor jedem Dienststart. Ist
|
||||
# alles heil, endet es nach Millisekunden. Sonst baut es die Umgebung neu --
|
||||
# aus denselben Paketen, die auch die Installation einspielt, denn es ist
|
||||
# dasselbe Programm: install.sh ruft es mit --immer auf. Zwei getrennte Listen
|
||||
# waeren zwei Listen, die auseinanderlaufen.
|
||||
#
|
||||
# **Was es nicht tut:** die alten Pakete unter lib/python3.X uebernehmen.
|
||||
# cryptography, argon2-cffi und bcrypt sind kompiliert und fuer die alte
|
||||
# Fassung gebaut; ein halb uebernommener venv waere schlimmer als ein neuer.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# **Der venv muss fuer den Dienstbenutzer lesbar sein.**
|
||||
#
|
||||
# Dieses Programm laeuft als ExecStartPre der Dienst-Unit -- und erbt damit
|
||||
# deren UMask=0027. Alles, was es anlegt, gehoerte root und waere 0750: der
|
||||
# Dienst selbst laeuft unprivilegiert und koennte sein eigenes site-packages
|
||||
# nicht einmal betreten. Gemessen: nach der Reparatur ging "import gunicorn"
|
||||
# als root, als Benutzer tesm nicht -- die Reparatur baute etwas, das genau
|
||||
# der Prozess nicht benutzen konnte, fuer den sie gedacht war.
|
||||
#
|
||||
# Im venv liegt nichts Geheimes; Schluessel und Datenbank stehen woanders.
|
||||
umask 022
|
||||
|
||||
ROOT="${1:-}"
|
||||
APP="${2:-}"
|
||||
MODUS="reparieren"
|
||||
[[ "${3:-}" == "--immer" ]] && MODUS="immer"
|
||||
[[ "${3:-}" == "--pruefen" ]] && MODUS="pruefen"
|
||||
|
||||
[[ -n "$ROOT" && -n "$APP" ]] || {
|
||||
echo "Aufruf: tesm-venv <verzeichnis> <tesm|tesm-license> [--immer|--pruefen]" >&2
|
||||
exit 2
|
||||
}
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "tesm-venv: unbekannte Anwendung '$APP'" >&2
|
||||
exit 2
|
||||
}
|
||||
[[ -d "$ROOT" ]] || { echo "tesm-venv: $ROOT gibt es nicht" >&2; exit 2; }
|
||||
|
||||
NAME="$(basename "$ROOT")"
|
||||
VENV="$ROOT/venv"
|
||||
PY="$VENV/bin/python3"
|
||||
MODUL="tesm"; [[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
|
||||
LOG_DIR="${TESM_LOG_DIR:-/var/log/$NAME}"
|
||||
#: Ein Versuch je Systemstart. /run ist ein tmpfs, der Merker verschwindet
|
||||
#: also beim naechsten Start von selbst. Ohne ihn wuerde "Restart=always" nach
|
||||
#: einem misslungenen Neuaufbau (kein Netz, kein PyPI) alle fuenf Sekunden
|
||||
#: einen neuen pip-Lauf starten -- gegen einen Server, der ohnehin gerade
|
||||
#: nicht erreichbar ist.
|
||||
MERKER="/run/tesm-venv-$NAME.versucht"
|
||||
|
||||
melden() {
|
||||
local stufe="$1" text="$2"
|
||||
# Ins Journal: ExecStartPre-Ausgabe landet dort ohnehin.
|
||||
printf 'tesm-venv: %s\n' "$text" >&2
|
||||
# Und ins laufende Protokoll, damit es in der Oberflaeche sichtbar ist.
|
||||
# Fehlschlaege hier duerfen den Start nicht kosten -- ein Protokoll ist
|
||||
# kein Grund, einen Dienst nicht zu starten.
|
||||
if [[ -d "$LOG_DIR" ]]; then
|
||||
printf '%s %-7s %-10s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$stufe" "venv" "$text" \
|
||||
>> "$LOG_DIR/live.log" 2>/dev/null || true
|
||||
chown "$APP:$APP" "$LOG_DIR/live.log" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
#: Traegt diese Umgebung? Gefragt wird nicht nach der Versionsnummer, sondern
|
||||
#: nach dem, was gebraucht wird -- so faellt auch ein venv auf, dem jemand ein
|
||||
#: Paket herausgeloescht hat.
|
||||
traegt() {
|
||||
[[ -x "$PY" ]] || return 1
|
||||
"$PY" -c "import pip" >/dev/null 2>&1 || return 1
|
||||
# Beim allerersten Lauf ist die Anwendung noch nicht eingespielt -- dann
|
||||
# zaehlt allein, dass pip benutzbar ist.
|
||||
[[ "$MODUS" == "immer" ]] && return 0
|
||||
"$PY" -c "import $MODUL" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
bestuecken() {
|
||||
# **Jeder pip-Lauf wird einzeln geprueft.**
|
||||
#
|
||||
# "set -e" hilft hier nicht: diese Funktion wird in einer Bedingung
|
||||
# aufgerufen ("if ! bestuecken"), und darin ist errexit fuer den **ganzen**
|
||||
# Rumpf abgeschaltet. Ein gescheitertes pip lief deshalb einfach weiter bis
|
||||
# zum "return 0" -- der Lauf meldete "Fertig", und im venv lagen weiter die
|
||||
# alten Pakete.
|
||||
#
|
||||
# Real passiert: der Lizenzserver blieb ueber drei Auslieferungen hinweg auf
|
||||
# 2.8.16 stehen, weil eine Abhaengigkeit nicht aufloesbar war. Gesehen hat es
|
||||
# niemand, denn die Fehlerzeile von pip ging im Lauf unter und der
|
||||
# Ausstiegscode war 0.
|
||||
"$PY" -m pip install --quiet --upgrade pip setuptools wheel || return 1
|
||||
local pakete=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
[[ -d "$ROOT/packages/tesm-licensing-server" ]] || {
|
||||
echo "tesm-venv: packages/tesm-licensing-server fehlt" >&2
|
||||
return 1
|
||||
}
|
||||
pakete+=("$ROOT/packages/tesm-licensing-server")
|
||||
fi
|
||||
"$PY" -m pip install --quiet "${pakete[@]}" "$ROOT/apps/$APP" gunicorn || return 1
|
||||
# Die WebSocket-Bruecke braucht nur TESM (Terminal und Bildschirm).
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
"$PY" -m pip install --quiet flask-sock || return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
if [[ "$MODUS" == "pruefen" ]]; then
|
||||
traegt && exit 0 || exit 1
|
||||
fi
|
||||
|
||||
if traegt && [[ "$MODUS" != "immer" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$MODUS" != "immer" && -e "$MERKER" ]]; then
|
||||
melden "WARNUNG" \
|
||||
"Die Python-Umgebung von $NAME ist unbrauchbar, und der Neuaufbau ist seit dem letzten Systemstart schon einmal gescheitert. Erneuter Versuch: 'bash $ROOT/deploy/install.sh --app $APP --yes'."
|
||||
exit 1
|
||||
fi
|
||||
touch "$MERKER" 2>/dev/null || true
|
||||
|
||||
if ! [[ -x "$PY" ]] || ! "$PY" -c "import pip" >/dev/null 2>&1; then
|
||||
# Ohne --clear: ein erneutes "venv" ergaenzt das fehlende lib-Verzeichnis
|
||||
# und holt pip zurueck. Gemessen, nicht angenommen.
|
||||
python3 -m venv "$VENV"
|
||||
[[ "$MODUS" == "immer" ]] ||
|
||||
melden "HINWEIS" \
|
||||
"Python-Umgebung von $NAME neu aufgebaut ($(python3 -V 2>&1)) -- das System hat seine Python-Fassung gewechselt."
|
||||
fi
|
||||
|
||||
if ! bestuecken; then
|
||||
melden "FEHLER" \
|
||||
"Die Pakete fuer $NAME liessen sich nicht einspielen. Hat dieser Server Zugriff auf das Paketverzeichnis?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Und noch einmal ausdruecklich, fuer den Fall, dass ein frueherer Lauf mit
|
||||
# strengerer Maske gebaut hat: was hier liegt, muss lesbar sein. Idempotent
|
||||
# und in einer Sekunde durch.
|
||||
chmod -R a+rX "$VENV" 2>/dev/null || true
|
||||
|
||||
# Vertrauen ist gut: nach dem Einspielen muss die Anwendung auch importierbar
|
||||
# sein. Ohne diese Probe hiesse "Fertig" nur, dass pip nicht geschimpft hat.
|
||||
if ! "$PY" -c "import $MODUL" >/dev/null 2>&1; then
|
||||
melden "FEHLER" "Die Pakete fuer $NAME wurden eingespielt, aber '$MODUL' laesst sich nicht importieren."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[[ "$MODUS" == "immer" ]] || melden "INFO" "Python-Umgebung von $NAME ist wieder vollstaendig."
|
||||
rm -f "$MERKER" 2>/dev/null || true
|
||||
exit 0
|
||||
@@ -29,6 +29,23 @@
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
|
||||
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
|
||||
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
|
||||
# nicht erzeugt, warnt **jede neu gestartete Shell**:
|
||||
#
|
||||
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
|
||||
#
|
||||
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
|
||||
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
|
||||
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
|
||||
# klingt, liest sich schlecht.
|
||||
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
|
||||
export LC_ALL=C.UTF-8 LANG=C.UTF-8
|
||||
else
|
||||
export LC_ALL=C LANG=C
|
||||
fi
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
PURGE=0
|
||||
|
||||
@@ -18,6 +18,23 @@
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
|
||||
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
|
||||
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
|
||||
# nicht erzeugt, warnt **jede neu gestartete Shell**:
|
||||
#
|
||||
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
|
||||
#
|
||||
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
|
||||
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
|
||||
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
|
||||
# klingt, liest sich schlecht.
|
||||
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
|
||||
export LC_ALL=C.UTF-8 LANG=C.UTF-8
|
||||
else
|
||||
export LC_ALL=C LANG=C
|
||||
fi
|
||||
|
||||
APP="${1:-}"
|
||||
TAG="${2:-latest}"
|
||||
|
||||
|
||||
@@ -383,6 +383,49 @@ sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
|
||||
---
|
||||
|
||||
## 4bb. Release-Upgrade des Betriebssystems
|
||||
|
||||
Ein `do-release-upgrade` tauscht die Minor-Fassung von Python aus (24.04 bringt
|
||||
3.12, 26.04 bringt 3.14). Der venv einer Installation hängt daran: seine Pakete
|
||||
liegen unter `lib/python3.12`, während `venv/bin/python3` auf `/usr/bin/python3`
|
||||
zeigt — also nach dem Upgrade auf den **neuen** Interpreter. Er ist ausführbar
|
||||
und trotzdem tot; jeder Dienst scheitert mit `No module named gunicorn`, nginx
|
||||
meldet 502.
|
||||
|
||||
**Das repariert TESM seit 2.8.33 von selbst.** Vor jedem Dienststart läuft
|
||||
|
||||
```
|
||||
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
|
||||
```
|
||||
|
||||
Ist die Umgebung heil, kostet das Millisekunden. Sonst wird sie neu gebaut und
|
||||
mit denselben Paketen bestückt, die auch die Installation einspielt — es ist
|
||||
dasselbe Programm, `install.sh` ruft es mit `--immer` auf. Nach dem Neustart,
|
||||
der auf jedes Release-Upgrade folgt, kommt der Dienst also von allein zurück;
|
||||
im Live-Protokoll steht dann eine Zeile als Hinweis.
|
||||
|
||||
Von Hand prüfen oder anstoßen:
|
||||
|
||||
```bash
|
||||
/usr/local/lib/tesm/tesm-venv /srv/tesm tesm --pruefen # 0 heil, 1 kaputt
|
||||
/usr/local/lib/tesm/tesm-venv /srv/tesm tesm # reparieren
|
||||
```
|
||||
|
||||
**Grenzen.** Der Neuaufbau lädt Pakete und braucht Zugriff auf das
|
||||
Paketverzeichnis. Ohne Netz kann er nicht gelingen: dann wird **ein** Versuch je
|
||||
Systemstart unternommen (Merker unter `/run`), und es bleibt bei einer klaren
|
||||
Zeile im Journal und im Live-Protokoll. Ohne diese Bremse startete
|
||||
`Restart=always` alle fünf Sekunden einen neuen `pip`-Lauf.
|
||||
|
||||
Die alten Pakete unter `lib/python3.<alt>` werden nicht übernommen —
|
||||
`cryptography`, `argon2-cffi` und `bcrypt` sind kompiliert und für die alte
|
||||
Fassung gebaut.
|
||||
|
||||
Empfohlene Reihenfolge bleibt trotzdem: erst TESM aktualisieren, dann
|
||||
`do-release-upgrade`, dann prüfen.
|
||||
|
||||
---
|
||||
|
||||
## 4c. Installation entfernen
|
||||
|
||||
Das Gegenstück zu `install.sh`:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.8.18"
|
||||
version = "2.8.21"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -162,6 +162,67 @@ class LicenseState:
|
||||
return dict(self.status.vendor) if self.status else {}
|
||||
|
||||
|
||||
#: Ausstiegscodes der Abfrage -- danach schlaegt ein RMM Alarm, nicht nach Text.
|
||||
ABFRAGE_OK = 0
|
||||
ABFRAGE_LAEUFT_AB = 1
|
||||
ABFRAGE_UNGUELTIG = 2
|
||||
|
||||
#: Ab hier gilt eine Lizenz als "laeuft bald ab". Dieselbe Zahl wie in der
|
||||
#: Oberflaeche -- zwei Schwellen fuer dieselbe Frage waeren eine zu viel.
|
||||
ABFRAGE_WARNSCHWELLE_TAGE = 30
|
||||
|
||||
|
||||
def zustand_als_auskunft(state: "LicenseState") -> dict[str, Any]:
|
||||
"""Der Lizenzzustand als schlichtes Woerterbuch -- fuer JSON und Skripte.
|
||||
|
||||
Gedacht fuer ein RMM, das auf der Maschine selbst laeuft: kein Netz, kein
|
||||
Token, keine Anmeldung. Was hier steht, beantwortet die eine Frage, die ein
|
||||
Kunde wirklich hat -- wie lange laeuft die Lizenz noch --, und nichts
|
||||
darueber hinaus: keine Schluessel, keine Kennungen anderer Installationen.
|
||||
"""
|
||||
status = state.status
|
||||
return {
|
||||
"valid": bool(state.valid),
|
||||
"activated": bool(state.activated),
|
||||
"type": str(status.type) if status else "",
|
||||
"role": state.role,
|
||||
"lifetime": bool(status.lifetime) if status else False,
|
||||
"expires_at": lic.iso(status.expires_at) if status and status.expires_at else "",
|
||||
# Bei einer unbefristeten Lizenz ist die Zahl bewusst ``null`` und
|
||||
# keine grosse Zahl: "noch 99999 Tage" waere eine Behauptung.
|
||||
"days_left": (status.days_left if status and not status.lifetime else None),
|
||||
"expiring_soon": bool(status.expiring_soon) if status else False,
|
||||
"grace_active": bool(status.grace_active) if status else False,
|
||||
# Drei Angaben, weil zwei davon verschieden sein koennen und die
|
||||
# Verwechslung teuer ist: "modules" ist, was die **Lizenz** freigibt --
|
||||
# dieselbe Liste, die der Lizenzserver zeigt. Was davon vor Ort
|
||||
# abgeschaltet wurde, steht daneben.
|
||||
#
|
||||
# Vorher stand hier allein das Laufende. Beim Kunden fehlte darin
|
||||
# "dhcp", waehrend die Lizenzseite es auswies -- es war lediglich unter
|
||||
# Verwaltung -> System abgeschaltet. Aus der Antwort ging das nicht
|
||||
# hervor, und damit sah es nach einem Lizenzproblem aus.
|
||||
"modules": sorted(state.modules),
|
||||
"modules_active": sorted(state.modules_in_use),
|
||||
"modules_off": sorted(state.modules_off),
|
||||
"customer": state.customer_name,
|
||||
"last_heartbeat_at": state.last_heartbeat_at,
|
||||
"error": state.error,
|
||||
}
|
||||
|
||||
|
||||
def abfrage_ausstiegscode(auskunft: dict[str, Any]) -> int:
|
||||
"""0 heil, 1 laeuft bald ab, 2 ungueltig -- die Sprache eines RMM."""
|
||||
if not auskunft.get("valid"):
|
||||
return ABFRAGE_UNGUELTIG
|
||||
if auskunft.get("lifetime"):
|
||||
return ABFRAGE_OK
|
||||
tage = auskunft.get("days_left")
|
||||
if tage is None or int(tage) <= ABFRAGE_WARNSCHWELLE_TAGE:
|
||||
return ABFRAGE_LAEUFT_AB
|
||||
return ABFRAGE_OK
|
||||
|
||||
|
||||
class LicenseManager:
|
||||
"""Haelt den Lizenzzustand und fuehrt die Handshakes durch."""
|
||||
|
||||
|
||||
@@ -1131,6 +1131,43 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* ---------------------------------------------------------------------- #
|
||||
Aufklappbares
|
||||
---------------------------------------------------------------------- #
|
||||
|
||||
Der native Marker eines <summary> verschwindet, sobald das Element ein
|
||||
Flex-Container ist -- und genau das sind unsere Kartenkoepfe. Auf der
|
||||
DHCP-Seite stand der Optionskatalog deshalb als Kasten da, dem man nicht
|
||||
ansah, dass er sich oeffnen laesst.
|
||||
|
||||
Der Pfeil wird deshalb selbst gezeichnet: als Flex- bzw. Inline-Element,
|
||||
das in beiden Faellen erscheint, in Akzentfarbe (das ist die Farbe, mit
|
||||
der diese Oberflaeche "hier kannst du etwas tun" sagt) und mit einer
|
||||
Drehung beim Oeffnen. Eine Stelle fuer alle Aufklapper -- Karten,
|
||||
Vorschauen, Abschnitte. */
|
||||
details > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
details > summary::-webkit-details-marker { display: none; }
|
||||
details:not(.nav-tree) > summary::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-right: var(--sp-2);
|
||||
border-left: 6px solid var(--accent);
|
||||
border-top: 5px solid transparent;
|
||||
border-bottom: 5px solid transparent;
|
||||
transition: transform var(--dur) var(--ease);
|
||||
}
|
||||
details:not(.nav-tree)[open] > summary::before { transform: rotate(90deg); }
|
||||
/* Der Navigationsbaum bringt seinen eigenen Winkel mit -- der bekommt
|
||||
dieselbe Farbe, damit "aufklappbar" ueberall gleich aussieht. */
|
||||
.nav-tree__chevron { color: var(--accent); }
|
||||
|
||||
.log-view mark { background: var(--warning-soft); color: var(--warning); border-radius: 3px; }
|
||||
|
||||
/* Stufen im laufenden Protokoll. Die Zuordnung der Farbrollen ist Absicht:
|
||||
|
||||
@@ -73,12 +73,11 @@ blieb dadurch über Jahre bestehen und lieferte alle statischen Dateien als 404.
|
||||
</section>
|
||||
|
||||
<details class="card" data-persist="nginx-preview">
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<summary class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration</div>
|
||||
<div class="card__sub">Vorschau der Datei, die geschrieben würde</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
<pre class="log-view">{{ preview }}</pre>
|
||||
</details>
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing"
|
||||
version = "2.7.7"
|
||||
version = "2.7.8"
|
||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["cryptography>=42"]
|
||||
|
||||
@@ -46,6 +46,7 @@ from .license import (
|
||||
key_id,
|
||||
license_status,
|
||||
parse_iso,
|
||||
tage_bis,
|
||||
split_bundle,
|
||||
utcnow,
|
||||
verify_license,
|
||||
@@ -103,6 +104,7 @@ __all__ = [
|
||||
"machine_identity",
|
||||
"new_nonce",
|
||||
"parse_iso",
|
||||
"tage_bis",
|
||||
"sign",
|
||||
"split_bundle",
|
||||
"system_fingerprint",
|
||||
|
||||
@@ -221,6 +221,27 @@ def verify_license(
|
||||
)
|
||||
|
||||
|
||||
def tage_bis(expires_at: datetime, *, now: datetime | None = None) -> int:
|
||||
"""Restlaufzeit in **angefangenen** Tagen.
|
||||
|
||||
Aufgerundet, und das ist eine Entscheidung: eine Lizenz, die in zwei
|
||||
Stunden ablaeuft, hat "1 Tag" und nicht "0". Null bedeutet hier
|
||||
ausschliesslich *abgelaufen* -- eine gueltige Lizenz, die mit "0 Tage"
|
||||
dasteht, liest sich wie eine tote.
|
||||
|
||||
**Es gibt genau diese eine Rechnung.** Der Lizenzserver hatte eine zweite,
|
||||
abgerundete: dieselbe Lizenz zeigte in seiner Oberflaeche "21 Tage" und im
|
||||
Abruf beim Kunden "22". Zwei Zahlen fuer dieselbe Frage sind schlimmer als
|
||||
eine ungenaue -- sie kosten jemanden einen Nachmittag mit der Suche nach
|
||||
einem Fehler, den es nicht gibt.
|
||||
"""
|
||||
moment = now or utcnow()
|
||||
sekunden = int((expires_at - moment).total_seconds())
|
||||
if sekunden <= 0:
|
||||
return 0
|
||||
return -(-sekunden // 86400)
|
||||
|
||||
|
||||
def license_status(
|
||||
document: Mapping[str, Any],
|
||||
*,
|
||||
@@ -278,7 +299,7 @@ def license_status(
|
||||
days_left = 0
|
||||
days_since_expiry = int((moment - expires_at).total_seconds() // 86400)
|
||||
else:
|
||||
days_left = -(-int(delta.total_seconds()) // 86400)
|
||||
days_left = tage_bis(expires_at, now=moment)
|
||||
days_since_expiry = None
|
||||
|
||||
grace_active = bool(
|
||||
|
||||
Reference in new issue
Block a user