Compare commits

...
9 Commits
21 changed files with 993 additions and 30 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.36"
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):
+26 -1
View File
@@ -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 "",
+38
View File
@@ -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)
+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>
@@ -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">
+16 -20
View File
@@ -418,26 +418,16 @@ info "Dateien abgeglichen (Instanzdaten unter data/ bleiben unangetastet)"
# -- venv --------------------------------------------------------------------
step "Python-Umgebung"
if [[ ! -x "$ROOT/venv/bin/python3" ]]; then
python3 -m venv "$ROOT/venv"
info "venv erzeugt"
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 --------------------------------------------
@@ -467,6 +457,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)"
+12
View File
@@ -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
View File
@@ -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
+12
View File
@@ -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
+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.
+152
View File
@@ -0,0 +1,152 @@
#!/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() {
"$PY" -m pip install --quiet --upgrade pip setuptools wheel
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
# Die WebSocket-Bruecke braucht nur TESM (Terminal und Bildschirm).
[[ "$APP" == "tesm" ]] && "$PY" -m pip install --quiet flask-sock
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
[[ "$MODUS" == "immer" ]] || melden "INFO" "Python-Umgebung von $NAME ist wieder vollstaendig."
rm -f "$MERKER" 2>/dev/null || true
exit 0
+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env bash
# =============================================================================
# Rueckbau einer Installation von TESM oder TESM-Lizenzserver.
#
# Aufruf:
# sudo bash deploy/uninstall.sh --app tesm
# sudo bash deploy/uninstall.sh --app tesm-license --instance test
# sudo bash deploy/uninstall.sh --app tesm --dry-run
#
# Das genaue Gegenstueck zu install.sh -- und es fehlte. Wer eine Installation
# von Hand loeschte ("rm -rf /srv/tesm-probe"), liess zurueck: die systemd-Unit
# (die systemd danach in einer Dauerschleife neu zu starten versuchte), den
# Begruessungshaken (der bei jeder Anmeldung seinen Banner druckte), den
# Shim unter /usr/local/bin, die Umgebungsdatei, die nginx-Site und den
# Dienstbenutzer. Auf dem Testserver standen so neun tote Dienste und vierzehn
# Banner. Genau das raeumt dieses Skript ab.
#
# Zwei Regeln, die den Unterschied machen:
#
# * **Daten bleiben.** Datenbank, Schluessel und Lizenz werden vorher in ein
# Archiv gelegt, nicht geloescht. Wer sie wirklich los sein will, sagt es
# mit --purge. Ein Rueckbau, der beilaeufig die Datenbank mitnimmt, ist
# keiner, sondern ein Unfall.
# * **Geteiltes bleibt, solange es jemand braucht.** sudoers-Datei und
# Dienstbenutzer gehoeren der *Anwendung* (nicht der Instanz), der Helfer
# unter /usr/local/lib/tesm allen Installationen beider Anwendungen. Sie
# verschwinden erst, wenn die letzte geht -- sonst legt der Rueckbau einer
# Testinstanz die Produktion daneben lahm.
# =============================================================================
set -euo pipefail
APP=""
INSTANCE=""
PURGE=0
ASSUME_YES=0
DRY_RUN=0
#: Ueberschreibbar, damit sich die Entscheidungen dieses Skripts in einem Test
#: wirklich ausfuehren lassen -- und nicht nur als Textmuster geprueft werden.
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
SRV_DIR="${TESM_SRV_DIR:-/srv}"
UNIT_DIR="${TESM_SYSTEMD_DIR:-/etc/systemd/system}"
MOTD_DIR="${TESM_MOTD_DIR:-/etc/update-motd.d}"
BIN_DIR="${TESM_BIN_DIR:-/usr/local/bin}"
LIB_DIR="${TESM_LIB_DIR:-/usr/local/lib/tesm}"
SUDOERS_DIR="${TESM_SUDOERS_DIR:-/etc/sudoers.d}"
LOGROTATE_DIR="${TESM_LOGROTATE_DIR:-/etc/logrotate.d}"
NGINX_DIR="${TESM_NGINX_DIR:-/etc/nginx}"
LOG_BASE="${TESM_LOG_BASE:-/var/log}"
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*" ; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; }
usage() {
cat <<'TEXT'
Rueckbau einer Installation.
--app <tesm|tesm-license> welche Anwendung (Pflicht)
--instance <name> benannte Instanz (sonst die Hauptinstallation)
--purge Daten nicht sichern, sondern loeschen
--dry-run nur zeigen, was geschehen wuerde
--yes, -y nicht nachfragen
TEXT
}
while [[ $# -gt 0 ]]; do
case "$1" in
--app) APP="${2:?}"; shift 2 ;;
--instance) INSTANCE="${2:?}"; shift 2 ;;
--purge) PURGE=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) fail "unbekannte Option: $1" ;;
esac
done
[[ -n "$APP" ]] || { usage; fail "--app fehlt"; }
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || fail "--app muss tesm oder tesm-license sein"
[[ -z "$INSTANCE" || "$INSTANCE" =~ ^[a-z0-9-]{1,16}$ ]] || fail "ungueltiger Instanzname"
# Root braucht es wegen /srv, /etc und systemd. Wer die Pfade umbiegt (Test),
# fasst nichts davon an -- dann ist die Huerde keine Sicherheit, sondern nur im
# Weg, und ein Rueckbau, der sich nicht pruefen laesst, ist ein Risiko fuer sich.
if [[ $DRY_RUN -eq 0 && "$SRV_DIR" == "/srv" && "$(id -u)" -ne 0 ]]; then
fail "bitte mit sudo ausfuehren"
fi
NAME="$APP"
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
ROOT="$SRV_DIR/$NAME"
# Das Datenverzeichnis kann verschoben sein -- dann steht es in der
# Umgebungsdatei, und nur dort. Ein Rueckbau, der stattdessen "$ROOT/data"
# annimmt, sichert ein leeres Verzeichnis und laesst die echte Datenbank liegen.
ENV_FILE="$ETC_TESM/$NAME.env"
DATEN_DIR="$(sed -n 's/^TESM_DATA_DIR=//p' "$ENV_FILE" 2>/dev/null | tail -1 || true)"
DATEN_DIR="${DATEN_DIR:-$ROOT/data}"
# --------------------------------------------------------------------------- #
# Wer teilt sich was?
# --------------------------------------------------------------------------- #
#: Gibt es neben dieser noch eine weitere Installation **derselben** Anwendung?
#: Daran haengen sudoers-Datei und Dienstbenutzer -- beide tragen den Namen der
#: Anwendung, nicht den der Instanz.
weitere_dieser_anwendung() {
local eintrag name
for eintrag in "$ETC_TESM/$APP.env" "$ETC_TESM/$APP-"*.env; do
[[ -f "$eintrag" ]] || continue
name="$(basename "$eintrag" .env)"
[[ "$name" == "$NAME" ]] && continue
return 0
done
return 1
}
#: Gibt es ueberhaupt noch irgendeine Installation? Daran haengen der Helfer
#: und das Banner-Programm unter /usr/local/lib/tesm -- die gehoeren allen.
weitere_ueberhaupt() {
local eintrag name
for eintrag in "$ETC_TESM"/*.env; do
[[ -f "$eintrag" ]] || continue
name="$(basename "$eintrag" .env)"
[[ "$name" == "$NAME" ]] && continue
return 0
done
return 1
}
# --------------------------------------------------------------------------- #
# Der Plan -- erst zeigen, dann tun
# --------------------------------------------------------------------------- #
UNITS=("$NAME.service" "$NAME-monitor.service" "$NAME-cleanup.service" "$NAME-cleanup.timer")
DATEIEN=(
"$NGINX_DIR/sites-enabled/$NAME"
"$NGINX_DIR/sites-available/$NAME"
"$MOTD_DIR/50-$NAME"
"$BIN_DIR/$NAME-admin"
"$LOGROTATE_DIR/$NAME"
"$ENV_FILE"
)
VERZEICHNISSE=("$ETC_TESM/certs/$NAME" "$LOG_BASE/$NAME")
gefunden=0
step "Rueckbau von $NAME"
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
info "Verzeichnis : $ROOT"
echo
echo " Dienste:"
for unit in "${UNITS[@]}"; do
if [[ -f "$UNIT_DIR/$unit" ]]; then info "- $unit"; gefunden=1; fi
done
[[ $gefunden -eq 1 ]] || info "(keine)"
echo
echo " Dateien und Verzeichnisse:"
for pfad in "${DATEIEN[@]}" "${VERZEICHNISSE[@]}"; do
if [[ -e "$pfad" ]]; then info "- $pfad"; gefunden=1; fi
done
if [[ -d "$ROOT" ]]; then info "- $ROOT"; gefunden=1; fi
if [[ $gefunden -eq 0 ]]; then
echo
info "Von '$NAME' ist nichts mehr da -- nichts zu tun."
exit 0
fi
echo
echo " Daten (Datenbank, Schluessel, Lizenz):"
if [[ -d "$DATEN_DIR" ]]; then
if [[ $PURGE -eq 1 ]]; then
warn "$DATEN_DIR wird GELOESCHT (--purge)"
else
info "$DATEN_DIR wird vorher gesichert"
fi
else
info "(kein Datenverzeichnis gefunden)"
fi
echo
echo " Bleibt stehen:"
if weitere_dieser_anwendung; then
info "sudoers und Dienstbenutzer '$APP' -- eine weitere Installation braucht sie"
else
info "- (sudoers und Dienstbenutzer '$APP' werden entfernt)"
fi
if weitere_ueberhaupt; then
info "Helfer unter $LIB_DIR -- andere Installationen brauchen ihn"
else
info "- (Helfer unter $LIB_DIR wird entfernt)"
fi
if [[ $DRY_RUN -eq 1 ]]; then
echo
info "Probelauf -- es wurde nichts geaendert."
exit 0
fi
if [[ $ASSUME_YES -eq 0 ]]; then
echo
read -r -p "Fortfahren? [j/N] " antwort
[[ "$antwort" =~ ^[jJyY]$ ]] || fail "abgebrochen"
fi
# --------------------------------------------------------------------------- #
# Ausfuehren
# --------------------------------------------------------------------------- #
step "Daten"
if [[ -d "$DATEN_DIR" && $PURGE -eq 0 ]]; then
ARCHIV="$SRV_DIR/$NAME-daten-$(date +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$ARCHIV" -C "$(dirname "$DATEN_DIR")" "$(basename "$DATEN_DIR")"
chmod 600 "$ARCHIV"
info "gesichert: $ARCHIV"
elif [[ -d "$DATEN_DIR" ]]; then
info "wird mit dem Verzeichnis geloescht (--purge)"
else
info "nichts zu sichern"
fi
step "Dienste"
for unit in "${UNITS[@]}"; do
[[ -f "$UNIT_DIR/$unit" ]] || continue
systemctl disable --now "$unit" >/dev/null 2>&1 || true
rm -f "$UNIT_DIR/$unit"
# Ohne das bleibt eine entfernte Unit als "not-found" im Fehlerzustand
# stehen und faerbt "systemctl --failed" rot.
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
info "entfernt: $unit"
done
systemctl daemon-reload >/dev/null 2>&1 || true
step "Webserver"
if [[ -e "$NGINX_DIR/sites-enabled/$NAME" || -e "$NGINX_DIR/sites-available/$NAME" ]]; then
rm -f "$NGINX_DIR/sites-enabled/$NAME" "$NGINX_DIR/sites-available/$NAME"
# Drei Faelle, und sie bedeuten Verschiedenes: kein nginx da (dann gibt es
# nichts neu zu laden), nginx da und zufrieden, nginx da und unzufrieden.
# Sie in einen Zweig zu werfen hiesse, auf einer Maschine ohne nginx eine
# Warnung ueber eine kaputte Konfiguration auszugeben, die es nicht gibt.
if ! command -v nginx >/dev/null 2>&1; then
info "Site entfernt (nginx ist hier nicht installiert)"
elif nginx -t >/dev/null 2>&1; then
systemctl reload nginx >/dev/null 2>&1 || true
info "Site entfernt, nginx neu geladen"
else
warn "Site entfernt -- nginx beanstandet die Konfiguration, bitte 'nginx -t' ansehen"
fi
else
info "keine Site vorhanden"
fi
step "Dateien"
for pfad in "${DATEIEN[@]}"; do
[[ -e "$pfad" ]] && { rm -f "$pfad"; info "entfernt: $pfad"; }
done
for pfad in "${VERZEICHNISSE[@]}"; do
[[ -d "$pfad" ]] && { rm -rf "$pfad"; info "entfernt: $pfad"; }
done
if [[ -d "$ROOT" ]]; then
rm -rf "$ROOT"
info "entfernt: $ROOT"
fi
if [[ $PURGE -eq 1 ]]; then
# Die Rueckwege aelterer Aktualisierungen. Sie enthalten dieselben Daten wie
# die Installation -- deshalb nur hier, nie im Normalfall.
for rest in "$SRV_DIR/$NAME-backup-"* "$SRV_DIR/$NAME-failed-"*; do
[[ -d "$rest" ]] && { rm -rf "$rest"; info "entfernt: $rest"; }
done
fi
step "Geteiltes"
if weitere_dieser_anwendung; then
info "sudoers und Dienstbenutzer '$APP' bleiben -- eine weitere Installation braucht sie"
else
rm -f "$SUDOERS_DIR/$APP"
info "entfernt: $SUDOERS_DIR/$APP"
if getent passwd "$APP" >/dev/null 2>&1; then
# Ein laufender Prozess haelt den Benutzer fest. Das ist kein Grund,
# den Rueckbau scheitern zu lassen -- der Dienst ist oben schon gestoppt,
# und was sonst noch unter diesem Namen laeuft, gehoert angesehen.
if userdel "$APP" >/dev/null 2>&1; then
info "entfernt: Dienstbenutzer $APP"
else
warn "Dienstbenutzer $APP liess sich nicht entfernen -- laeuft noch etwas unter ihm?"
fi
fi
fi
if weitere_ueberhaupt; then
info "Helfer unter $LIB_DIR bleibt -- andere Installationen brauchen ihn"
else
rm -rf "$LIB_DIR"
rmdir "$ETC_TESM/certs" "$ETC_TESM" >/dev/null 2>&1 || true
info "entfernt: $LIB_DIR"
fi
step "Fertig"
info "'$NAME' ist zurueckgebaut."
if [[ $PURGE -eq 0 && -n "${ARCHIV:-}" ]]; then
info "Die Daten liegen unter $ARCHIV -- dort und sonst nirgends."
fi
+81
View File
@@ -383,6 +383,87 @@ 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`:
```bash
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm --dry-run
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm-license --instance test --yes
```
Entfernt werden Dienste (`.service`, `-monitor`, `-cleanup` samt Timer), die
nginx-Site, der Begrüßungshaken, der Shim unter `/usr/local/bin`, die
Umgebungsdatei, das Zertifikatsverzeichnis, das Protokollverzeichnis und die
Installation selbst.
**Die Daten bleiben.** Datenbank, Schlüssel und Lizenz gehen vorher in ein
Archiv (`/srv/<name>-daten-<zeit>.tar.gz`, nur root). Sie sind das einzige an
einer Installation, was sich nicht wiederherstellen lässt. Wer sie wirklich
los sein will, nimmt `--purge` — das löscht zusätzlich die Rückwege älterer
Aktualisierungen (`/srv/<name>-backup-*`).
**Geteiltes bleibt, solange es jemand braucht.** Die sudoers-Datei und der
Dienstbenutzer tragen den Namen der *Anwendung*, nicht den der Instanz; der
Helfer unter `/usr/local/lib/tesm` gehört allen Installationen beider
Anwendungen. Sie verschwinden erst mit der letzten. Sonst legte der Rückbau
einer Testinstanz die Produktion daneben lahm.
`--dry-run` zeigt den Plan, ohne etwas zu ändern. Ein zweiter Lauf auf eine
bereits entfernte Installation sagt das und endet mit 0.
Warum es das gibt: wer eine Installation von Hand löscht (`rm -rf /srv/…`),
lässt Unit, Begrüßungshaken, Shim, sudoers-Datei und nginx-Site zurück.
systemd versucht die Unit danach in einer Dauerschleife zu starten, und der
Haken druckt bei jeder Anmeldung seinen Banner. Auf dem Testserver standen so
neun tote Dienste und vierzehn Banner.
---
## 5. Dienste
```bash
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.8.16"
version = "2.8.19"
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(
@@ -162,6 +162,56 @@ 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,
"modules": sorted(state.modules_in_use),
"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."""
@@ -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")),