Compare commits

4 Commits
Author SHA1 Message Date
alientimandClaude Opus 5 47aeaba9c8 rename.sh: aus einer Instanz die Hauptinstallation machen
Die laufenden Installationen hiessen "tesm-opus" und "tesm-license-opus" --
ein Instanzname aus der Zeit, in der sie neben der Fassung 1.x getestet wurden.
Jetzt heissen sie "tesm" und "tesm-license".

Umbenennen ist mehr als ein verschobenes Verzeichnis, und drei Stellen sind
leicht zu uebersehen:

* SITE_KEY in der Umgebungsdatei -- daraus entsteht der Name des
  Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
  Instanz: unauffaellig und falsch.
* web_cert_path/web_key_path/web_acme_webroot in der Datenbank enthalten den
  Namen. Ein Zertifikat unter dem alten Pfad findet nginx nicht mehr.
* server_name der nginx-Site. install.sh schreibt die Datei aus seiner Vorlage
  neu, sobald sich die alias-Pfade aendern -- und die aendern sich hier
  zwangslaeufig. Ohne den Namen aus der Datenbank stehen danach zwei Sites mit
  "server_name _" auf Port 80, und die erste gewinnt. web-setup braucht dafuer
  --apply; ohne ihn speichert es nur.

Zwei Fehler hat erst der Lauf am System gezeigt:

* Das venv wandert nicht mit. Seine Konsolenskripte tragen den absoluten Pfad
  im Shebang, und danach zeigt venv/bin/gunicorn auf einen Interpreter, den es
  nicht mehr gibt -- systemd meldet "No such file or directory" fuer eine
  Datei, die sichtbar da ist. Es wird jetzt verworfen und neu gebaut.
* Den Zielnamen belegt nicht nur /srv/<name>, sondern auch /var/log/<name>,
  der ACME-Pfad und die logrotate-Datei. Der Umzug brach mitten drin ab. Jetzt
  wandert alles, was den Namen belegt, in ein Archiv -- geloescht wird nichts,
  die Units und die Site der alten Fassung liegen darin unter systemd-alt/.

Ein Test vergleicht die Pfade, die install.sh unter dem Namen anlegt, mit
denen, die rename.sh kennt. Genau diese Luecke war der zweite Fehler; ohne den
Test faellt der naechste erst wieder auf einem Server auf.

Version 2.0.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:12:43 +02:00
alientimandClaude Opus 5 d31173fa5e Banner: Einzahl, wo eines gemeint ist
"1 Clients online" liest man jeden Tag einmal zu oft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:48:35 +02:00
alientimandClaude Opus 5 05885bfff1 Anmeldebanner wieder in Betrieb -- ohne die Anwendung zu starten
Das Banner der Vorgaengerfassung hing an /srv/tesm/motd_banner.py und
importierte dafuer die ganze Flask-Anwendung samt deren venv. Nach dem
Umstieg zeigte es damit die Zahlen einer Datenbank, die niemand mehr fuellt --
eingefroren, aber unverdaechtig. Aufgefallen ist es erst, als der alte
Ueberwachungsdienst abgeschaltet wurde.

deploy/tesm-motd loest das anders: **nur Standardbibliothek**, kein venv, kein
Import der Anwendung, die Datenbank wird nur lesend geoeffnet (mode=ro). Ein
Lauf dauert Millisekunden statt eines Flask-Starts. install.sh legt das Skript
nach /usr/local/lib/tesm/ und schreibt /etc/update-motd.d/50-<name>, abgesichert
mit "timeout 3" und "|| true" -- ein Banner darf eine Anmeldung nie blockieren.
Ein Banner der Vorgaengerfassung wird entfernt, aber nur, wenn es genau auf
motd_banner.py zeigt.

Damit es nicht zwei Wahrheiten ueber "online" gibt, steht die Zaehlung der
Statusuebersicht jetzt als Konstante DASHBOARD_COUNT_SQL in monitor.py; das
Banner traegt sie wortgleich, und ein Hygienetest vergleicht beide. Dasselbe
fuer die Zeile unter der Wortmarke. Ein weiterer Test verbietet dem Banner
jeden Import ausserhalb der Standardbibliothek -- genau dieser Import war die
Ursache.

Der Lizenzserver bekommt sein eigenes Banner (Kunden und ausgestellte
Lizenzen). Eine Tagline hat er bewusst nicht: die Funktionsliste von TESM waere
dort falsch, und eine erfundene waere schlimmer als keine.

Version 2.0.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:44:24 +02:00
alientimandClaude Opus 5 168d10bf87 Leerlaufabmeldung: die zweite Schreibstelle und ein Tab, der das alte Skript faehrt
Die Abmeldung hat weiterhin nicht ausgeloest. Zwei Gruende, beide fuer sich
ausreichend:

* **save_session** schreibt ``idle_expires_at`` bei jeder Anfrage, die die
  Sitzung anfasst -- ein neu erzeugtes CSRF-Token genuegt. Die Pruefung in
  ``open_session`` lief damit ins Leere. Sitzungsdaten werden weiter
  gespeichert, nur die Fristen bleiben stehen. Dieselbe Stelle benutzte
  ausserdem ``idle_minutes`` statt der einstellbaren Zeit; bei der Vorgabe von
  dreissig Minuten fiel das nicht auf.
* **Der Kopf allein reicht nicht.** Er setzt voraus, dass der Browser das
  aktuelle Skript faehrt -- ein Tab, der seit gestern Abend offen ist, faehrt
  das alte und schickt ihn nicht. Genau dieser Tab ist der Fall, um den es
  geht. Der Server weiss es ohne Zutun des Browsers: die Endpunkte, die eine
  Kachel oder ein Protokoll nachlaedt, sind als Aktualisierungsausschnitt
  gekennzeichnet (``@fragment``) und gelten damit immer als automatisch.
  ``request.endpoint`` steht in ``open_session`` noch nicht -- Flask oeffnet
  die Sitzung vor dem Routing --, deshalb sieht die Erkennung dort selbst nach.

Dass es auf dem Lizenzserver zuverlaessig funktionierte, war der Hinweis: dort
pollt keine Kachel.

Dazu ein Kopf ``X-Session-Expired`` fuer Seiten, die auch oeffentlich
antworten. Die Statusuebersicht liefert nach dem Ablauf eine 200 mit den
oeffentlichen Kacheln -- am Status ist der Verlust der Sitzung also nicht zu
erkennen, und der offene Tab zeigte weiter eine Oberflaeche, als sei alles in
Ordnung. Jetzt laedt er neu und zeigt die Anmeldemaske.

Version 2.0.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:21:38 +02:00
12 changed files with 775 additions and 9 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-license"
version = "2.0.3"
version = "2.0.6"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11"
dependencies = [
+36
View File
@@ -295,6 +295,42 @@ install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP"
rm -f "$TMP_SUDO"
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
# -- Anmeldebanner (MOTD) ----------------------------------------------------
# Das Banner liest die Datenbank dieser Installation und laeuft dabei mit dem
# System-Python, ohne das venv und ohne die Anwendung zu importieren. Der
# Vorgaenger importierte dafuer die ganze Flask-App -- bei jeder Anmeldung, und
# nach einem Wechsel des Quellverzeichnisses zeigte er stillschweigend die
# Zahlen einer Datenbank, die niemand mehr fuellte.
#
# "timeout" verhindert, dass ein haengender Dateizugriff die Anmeldung
# blockiert; "|| true" sorgt dafuer, dass ein Fehler hier den Login nie stoert.
step "Anmeldebanner"
if [[ -d /etc/update-motd.d ]]; then
install_if_changed "$ROOT/deploy/tesm-motd" /usr/local/lib/tesm/tesm-motd 0755 >/dev/null \
&& info "Bannerskript aktualisiert" || info "Bannerskript unveraendert"
TMP_MOTD="$(mktemp)"
{
echo "#!/bin/sh"
echo "# Von TESM erzeugt ($NAME). Aenderungen werden bei der naechsten"
echo "# Installation ueberschrieben."
echo "timeout 3 /usr/local/lib/tesm/tesm-motd --app $APP --root $ROOT 2>/dev/null || true"
} > "$TMP_MOTD"
install -o root -g root -m 0755 "$TMP_MOTD" "/etc/update-motd.d/50-$NAME"
rm -f "$TMP_MOTD"
info "Banner: /etc/update-motd.d/50-$NAME"
# Ein Banner der Vorgaengerfassung zeigt Zahlen aus einer Datenbank, die
# niemand mehr fuellt. Er wird nur entfernt, wenn er genau darauf zeigt --
# nichts anderes wird angefasst.
ALT_MOTD="/etc/update-motd.d/50-tesm"
if [[ "$ALT_MOTD" != "/etc/update-motd.d/50-$NAME" && -f "$ALT_MOTD" ]] \
&& grep -q "motd_banner.py" "$ALT_MOTD" 2>/dev/null; then
rm -f "$ALT_MOTD"
info "Banner der Vorgaengerfassung entfernt ($ALT_MOTD)"
fi
else
info "Kein /etc/update-motd.d -- Banner uebersprungen"
fi
# -- Rechte ------------------------------------------------------------------
step "Dateirechte"
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env bash
# =============================================================================
# Benennt eine Installation um -- aus einer Instanz wird die Hauptinstallation
# (oder umgekehrt).
#
# sudo bash deploy/rename.sh --app tesm --from tesm-opus --to tesm
# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus \
# --to tesm-license
#
# Der Name steckt an mehr Stellen, als man beim Verschieben eines Verzeichnisses
# erwartet, und drei davon sind leicht zu vergessen:
#
# * ``SITE_KEY`` in der Umgebungsdatei -- daraus entsteht der Name des
# Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
# Instanz.
# * ``web_cert_path``/``web_key_path``/``web_acme_webroot`` in der Datenbank.
# Sie enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx
# nach dem Umzug nicht mehr.
# * ``server_name`` der nginx-Site. install.sh schreibt die Datei neu, sobald
# sich die alias-Pfade aendern -- und die aendern sich hier zwangslaeufig.
# Ohne den Namen aus der Datenbank stuenden danach zwei Sites mit ``_`` auf
# Port 80 und die erste gewinnt.
#
# Belegt der Zielname noch eine Installation der Fassung 1.x, wird sie beiseite
# geschoben: Verzeichnis nach ``<ziel>-<suffix>``, ihre systemd-Units und ihre
# nginx-Site als Kopie hinein. Danach ist der Name frei und nichts auf dem
# System zeigt mehr ins Leere.
#
# Alle angemeldeten Benutzer werden abgemeldet: das Sitzungscookie heisst
# danach anders.
# =============================================================================
set -euo pipefail
APP=""
FROM=""
TO=""
SUFFIX="sonnet5"
ASSUME_YES=0
DRY_RUN=0
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
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 <<'USAGE'
Verwendung: rename.sh --app <tesm|tesm-license> --from <alt> --to <neu> [Optionen]
--app <name> Welche Anwendung (Pflicht)
--from <name> Bisheriger Installationsname, z.B. tesm-opus (Pflicht)
--to <name> Neuer Installationsname, z.B. tesm (Pflicht)
--suffix <text> Namenszusatz fuer eine beiseite geschobene Installation
der Fassung 1.x (Vorgabe: sonnet5)
--dry-run Nur zeigen, was geschehen wuerde
--yes Keine Rueckfrage
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--app) APP="${2:?}"; shift 2 ;;
--from) FROM="${2:?}"; shift 2 ;;
--to) TO="${2:?}"; shift 2 ;;
--suffix) SUFFIX="${2:?}"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) usage; fail "unbekannte Option: $1" ;;
esac
done
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
[[ -n "$FROM" && -n "$TO" ]] || { usage; fail "--from und --to sind Pflicht"; }
[[ "$FROM" != "$TO" ]] || fail "--from und --to sind gleich"
[[ "$TO" =~ ^[a-z0-9-]{1,32}$ ]] || fail "ungueltiger Zielname: $TO"
[[ "$TO" == "$APP" || "$TO" == "$APP-"* ]] || fail "der Zielname muss mit '$APP' beginnen"
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
[[ -d "/srv/$FROM" ]] || fail "/srv/$FROM gibt es nicht"
[[ -f "/srv/$FROM/data/app.db" ]] || fail "/srv/$FROM sieht nicht nach einer Installation dieser Fassung aus"
ENV_PREFIX="TESM_"
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
# Alles, was den Namen traegt. Reihenfolge: erst Daten, dann Konfiguration.
VERSCHIEBEN=(
"/srv/$FROM:/srv/$TO"
"/var/log/$FROM:/var/log/$TO"
"/etc/tesm/$FROM.env:/etc/tesm/$TO.env"
"/var/www/$FROM-acme:/var/www/$TO-acme"
"/etc/tesm/certs/$FROM:/etc/tesm/certs/$TO"
)
ENTFERNEN=(
"/etc/systemd/system/$FROM.service"
"/etc/systemd/system/$FROM-monitor.service"
"/etc/nginx/sites-enabled/$FROM"
"/etc/nginx/sites-available/$FROM"
"/usr/local/bin/$FROM-admin"
"/etc/logrotate.d/$FROM"
"/etc/update-motd.d/50-$FROM"
)
# -- Webeinstellungen auslesen, bevor irgendetwas umzieht -------------------- #
# Mit dem System-Python und der Standardbibliothek: das venv zieht gleich mit
# um, und ein zusaetzliches Werkzeug (sqlite3) ist auf einem schlanken Server
# nicht garantiert.
lese_einstellung() {
python3 - "$1" "/srv/$FROM/data/app.db" <<'PY'
import sqlite3, sys
schluessel, datenbank = sys.argv[1], sys.argv[2]
try:
conn = sqlite3.connect(f"file:{datenbank}?mode=ro", uri=True)
row = conn.execute("SELECT value FROM settings WHERE key=?", (schluessel,)).fetchone()
conn.close()
except sqlite3.Error:
row = None
print((row[0] if row and row[0] is not None else "").strip())
PY
}
DOMAIN="$(lese_einstellung web_domain || true)"
HTTPS_AN="$(lese_einstellung web_https_enabled || true)"
HSTS_AN="$(lese_einstellung web_hsts_enabled || true)"
CERT_ALT="$(lese_einstellung web_cert_path || true)"
step "Vorhaben"
info "Anwendung : $APP"
info "Alt : $FROM"
info "Neu : $TO"
info "Domain : ${DOMAIN:-(keine)}"
info "HTTPS : $([[ "$HTTPS_AN" == "1" ]] && echo "ja${CERT_ALT:+, Zertifikat $CERT_ALT}" || echo nein)"
for paar in "${VERSCHIEBEN[@]}"; do
quelle="${paar%%:*}"; ziel="${paar##*:}"
[[ -e "$quelle" ]] && info "verschieben: $quelle -> $ziel"
done
for pfad in "${ENTFERNEN[@]}"; do
[[ -e "$pfad" ]] && info "entfernen : $pfad (install.sh legt es unter dem neuen Namen neu an)"
done
# -- Was belegt den Zielnamen? ----------------------------------------------- #
# Nicht nur das Verzeichnis unter /srv: eine Installation der Fassung 1.x
# hinterlaesst auch /var/log/<name>, /etc/logrotate.d/<name> und den
# ACME-Pfad. Der Umzug bricht sonst mitten drin ab -- live genau so passiert.
BELEGT=()
for paar in "${VERSCHIEBEN[@]}"; do
ziel="${paar##*:}"
[[ -e "$ziel" ]] && BELEGT+=("$ziel")
done
for weiteres in "/etc/logrotate.d/$TO" "/etc/update-motd.d/50-$TO"; do
[[ -e "$weiteres" ]] && BELEGT+=("$weiteres")
done
BEISEITE=""
if [[ ${#BELEGT[@]} -gt 0 ]]; then
if [[ -f "/srv/$TO/data/app.db" ]]; then
fail "/srv/$TO ist bereits eine Installation dieser Fassung. Welche gewinnt, entscheidet niemand automatisch."
fi
BEISEITE="/srv/$TO-$SUFFIX"
if [[ -e "$BEISEITE" ]]; then
# Ein vorhandenes Archiv derselben Verdraengung wird weiterbenutzt -- die
# Reste einer Installation gehoeren zusammen und nicht in ein zweites
# Verzeichnis. Alles andere unter diesem Namen bleibt unangetastet.
if [[ -f "$BEISEITE/sqlite.db" || -d "$BEISEITE/systemd-alt" ]]; then
info "Archiv $BEISEITE ist vorhanden und wird weiterbenutzt."
else
fail "$BEISEITE existiert und sieht nicht nach einem solchen Archiv aus -- bitte --suffix waehlen"
fi
fi
info "Der Zielname ist belegt. Alles davon wandert nach $BEISEITE:"
for pfad in "${BELEGT[@]}"; do info " $pfad"; done
fi
if [[ $DRY_RUN -eq 1 ]]; then
step "--dry-run: hier ist Schluss."
exit 0
fi
if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Fortfahren? Alle angemeldeten Benutzer werden abgemeldet. [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
fi
# -- 1. Dienste anhalten ----------------------------------------------------- #
step "Dienste anhalten"
for unit in "$FROM.service" "$FROM-monitor.service"; do
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
systemctl disable --now "$unit" >/dev/null 2>&1 || true
info "$unit gestoppt und deaktiviert"
fi
done
# -- 2. Zielnamen freimachen ------------------------------------------------- #
if [[ -n "$BEISEITE" ]]; then
step "Zielnamen freimachen"
# Alles wandert ins Archiv, nichts wird geloescht: unter /var/log/<name>
# liegen Protokolle, unter /srv/<name> im Zweifel eine ganze Installation.
# Die Units und die Site kommen mit -- sie zeigen auf das alte Verzeichnis
# und waeren auf dem System nur noch Fehlerquellen, im Archiv aber ein Beleg.
mkdir -p "$BEISEITE"
for pfad in "${BELEGT[@]}"; do
ziel="$BEISEITE/$(basename "$pfad")"
if [[ "$pfad" == "/srv/$TO" ]]; then
# Das Verzeichnis selbst wird das Archiv -- sein Inhalt bleibt, wie er ist.
for eintrag in "$pfad"/* "$pfad"/.[!.]*; do
[[ -e "$eintrag" ]] || continue
mv "$eintrag" "$BEISEITE/"
done
rmdir "$pfad"
info "/srv/$TO -> $BEISEITE/"
continue
fi
if [[ -e "$ziel" ]]; then
ziel="$ziel-$(date +%Y%m%d-%H%M%S)"
warn "Im Archiv lag schon etwas mit diesem Namen -- ablegen als $(basename "$ziel")"
fi
mv "$pfad" "$ziel"
info "$pfad -> $ziel"
done
mkdir -p "$BEISEITE/systemd-alt"
for unit in "/etc/systemd/system/$TO.service" "/etc/systemd/system/$TO-check.service" \
"/etc/systemd/system/$TO-check.timer" "/etc/systemd/system/$TO-check-restart.service" \
"/etc/systemd/system/$TO-check-restart.timer"; do
[[ -f "$unit" ]] || continue
systemctl disable --now "$(basename "$unit")" >/dev/null 2>&1 || true
mv "$unit" "$BEISEITE/systemd-alt/"
info "$(basename "$unit") archiviert"
done
for site in "/etc/nginx/sites-enabled/$TO" "/etc/nginx/sites-available/$TO"; do
[[ -e "$site" ]] || continue
[[ -L "$site" ]] && { rm -f "$site"; continue; }
mv "$site" "$BEISEITE/systemd-alt/nginx-site"
info "nginx-Site archiviert"
done
for motd in "/etc/update-motd.d/50-$TO"; do
[[ -f "$motd" ]] && grep -q "motd_banner.py" "$motd" 2>/dev/null && rm -f "$motd" \
&& info "Banner der Fassung 1.x entfernt"
done
info "Alles Verdraengte liegt unter $BEISEITE"
fi
# -- 3. Umziehen ------------------------------------------------------------- #
step "Umziehen"
for paar in "${VERSCHIEBEN[@]}"; do
quelle="${paar%%:*}"; ziel="${paar##*:}"
[[ -e "$quelle" ]] || continue
[[ -e "$ziel" ]] && fail "$ziel existiert bereits -- Abbruch, damit nichts vermischt wird"
mkdir -p "$(dirname "$ziel")"
mv "$quelle" "$ziel"
info "$quelle -> $ziel"
done
# -- 4. Namen in den Dateien nachziehen -------------------------------------- #
step "Namen nachziehen"
ENV_FILE="/etc/tesm/$TO.env"
if [[ -f "$ENV_FILE" ]]; then
# Der SITE_KEY bestimmt den Namen des Sitzungscookies. Bleibt er alt, heisst
# das Cookie weiter wie die alte Instanz -- unauffaellig und falsch.
sed -i "s#^${ENV_PREFIX}SITE_KEY=.*#${ENV_PREFIX}SITE_KEY=$TO#" "$ENV_FILE"
info "SITE_KEY=$TO"
fi
# Pfade in der Datenbank, die den Namen enthalten.
python3 - "$TO" "$FROM" "/srv/$TO/data/app.db" <<'PY'
import sqlite3, sys
neu, alt, datenbank = sys.argv[1], sys.argv[2], sys.argv[3]
schluessel = ("web_cert_path", "web_key_path", "web_acme_webroot")
try:
conn = sqlite3.connect(datenbank, timeout=5.0)
except sqlite3.Error as fehler:
print(f" Datenbank nicht schreibbar: {fehler}")
raise SystemExit(0)
try:
with conn:
for key in schluessel:
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
if not row or not row[0]:
continue
wert = str(row[0])
ersetzt = wert.replace(alt, neu)
if ersetzt != wert:
conn.execute("UPDATE settings SET value=? WHERE key=?", (ersetzt, key))
print(f" {key}: {wert} -> {ersetzt}")
finally:
conn.close()
PY
# -- 5. Alte Konfiguration entfernen ----------------------------------------- #
step "Alte Konfiguration entfernen"
for pfad in "${ENTFERNEN[@]}"; do
[[ -e "$pfad" ]] || continue
rm -f "$pfad"
info "$pfad"
done
systemctl daemon-reload
# -- 6. Unter dem neuen Namen einrichten ------------------------------------- #
# Das venv wird verworfen und neu gebaut. Es ist abgeleiteter Zustand, und
# seine Konsolenskripte tragen den absoluten Pfad im Shebang: nach dem Umzug
# zeigt "venv/bin/gunicorn" auf einen Interpreter, den es nicht mehr gibt, und
# systemd meldet nur "No such file or directory" -- fuer eine Datei, die
# sichtbar da ist. Live auf POETEST genau so passiert.
if [[ -d "/srv/$TO/venv" ]]; then
step "venv verwerfen (Shebangs tragen den alten Pfad)"
rm -rf "/srv/$TO/venv"
info "wird von install.sh neu gebaut"
fi
step "Unter dem neuen Namen einrichten"
INSTALL_ARGS=(--app "$APP" --yes)
[[ "$TO" != "$APP" ]] && INSTALL_ARGS+=(--instance "${TO#"$APP"-}")
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}"
# -- 7. nginx-Site aus den Einstellungen neu schreiben ----------------------- #
# install.sh schreibt die Site aus seiner Vorlage, sobald sich die alias-Pfade
# aendern -- und die aendern sich hier zwangslaeufig. Der Name der Site steht
# aber in der Datenbank, nicht in der Vorlage. Ohne diesen Schritt stuenden
# zwei Sites mit "_" auf Port 80 und die erste gewinnt.
if [[ -n "$DOMAIN" ]]; then
step "Webserver-Konfiguration erneuern"
# --apply nicht vergessen: ohne ihn speichert web-setup nur die Einstellungen
# und laesst die Site-Datei stehen, die install.sh aus seiner Vorlage
# geschrieben hat -- mit "server_name _" und ohne TLS.
WEB_ARGS=(web-setup --apply --domain "$DOMAIN")
if [[ "$HTTPS_AN" == "1" ]]; then
WEB_ARGS+=(--https)
[[ "$HSTS_AN" == "1" ]] && WEB_ARGS+=(--hsts)
# Das Zertifikat ist mitgezogen und sein Pfad in der Datenbank nachgefuehrt
# -- "web-setup --https" laesst es dann unangetastet. Ein neues erzeugen
# wuerde nur eine weitere Browserwarnung produzieren. Nur wenn nach dem
# Umzug wirklich keines da ist, wird eines erzeugt.
CERT_NEU="${CERT_ALT//$FROM/$TO}"
if [[ -n "$CERT_NEU" && -f "$CERT_NEU" ]]; then
info "HTTPS mit dem vorhandenen Zertifikat: $CERT_NEU"
else
WEB_ARGS+=(--self-signed)
warn "Zertifikat nicht gefunden (${CERT_NEU:-keins hinterlegt}) -- es wird ein neues"
warn "selbstsigniertes erzeugt. Der Browser warnt dann einmal erneut."
fi
fi
if sudo -u "$APP" "/usr/local/bin/$TO-admin" "${WEB_ARGS[@]}"; then
info "Site neu geschrieben: server_name $DOMAIN"
else
warn "web-setup ist fehlgeschlagen -- bitte in der Anwendung unter"
warn "'Verwaltung -> Webserver und TLS' die Domain eintragen und uebernehmen."
fi
fi
# -- 8. Ergebnis ------------------------------------------------------------- #
step "Fertig"
info "Verzeichnis : /srv/$TO"
info "Dienst : systemctl status $TO.service"
info "Werkzeug : $TO-admin"
info "Aktualisieren: sudo bash /srv/$TO/deploy/update.sh $APP$([[ "$TO" != "$APP" ]] && echo " latest --instance ${TO#"$APP"-}")"
[[ -n "$BEISEITE" ]] && info "Fassung 1.x : $BEISEITE (Units und Site darin unter systemd-alt/)"
echo
warn "Alle Benutzer sind abgemeldet: das Sitzungscookie heisst jetzt anders."
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Live-Banner fuer die SSH-Anmeldung (MOTD).
Eingebunden ueber ``/etc/update-motd.d/50-<name>``, das ``install.sh``
erzeugt. Aufruf mit ``--app`` und ``--root``; alles andere steht in der
Datenbank der Installation.
**Nur Standardbibliothek, kein Flask, kein venv.** Der Vorgaenger importierte
dafuer die ganze Anwendung -- bei jeder SSH-Anmeldung. Das ist teuer, und es
faellt aus, sobald das venv nicht mehr passt: nach dem Umstieg auf diese
Fassung zeigte das alte Banner monatelang eingefrorene Zahlen aus einer
Datenbank, die niemand mehr fuellte. Hier wird gelesen, was auf der Platte
liegt, und zwar **nur lesend** (``mode=ro``).
Ein Banner darf eine Anmeldung niemals stoeren: jede Auskunft, die nicht zu
holen ist, wird zu einem kurzen Hinweis, und am Ende faengt ``main`` alles ab.
Der Aufrufer setzt zusaetzlich ein Zeitlimit.
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
import time
from pathlib import Path
AMBER = "\033[38;2;226;166;60m"
DIM = "\033[2m"
BOLD = "\033[1m"
RESET = "\033[0m"
#: Kea schreibt seine Leases als CSV; die letzte Zeile je Adresse gilt.
#: Dieselbe Datei liest die Anwendung (``services/kea.py``: ``LEASE_FILE``).
LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
#: Zeile unter der Wortmarke. Wortlaut wie in der Anwendung
#: (``apps/tesm/src/tesm/__init__.py``: ``AUTH_TAGLINE``) -- ein Hygienetest
#: haelt beides zusammen.
#:
#: Der Lizenzserver hat bewusst keine: die Funktionsliste von TESM waere dort
#: falsch, und eine erfundene waere schlimmer als keine.
TAGLINE = {
"tesm": "UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)",
"tesm-license": "",
}
TITEL = {"tesm": "TimEShepManager", "tesm-license": "Lizenzserver"}
#: Wortgleich mit ``services/monitor.py``: ``DASHBOARD_COUNT_SQL``. Die
#: Statusuebersicht und dieses Banner sollen nicht zwei Wahrheiten haben; ein
#: Hygienetest vergleicht die beiden Zeichenketten.
DASHBOARD_COUNT_SQL = """
SELECT
COUNT(*) AS total,
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='online' THEN 1 ELSE 0 END) AS online,
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='offline' THEN 1 ELSE 0 END) AS offline,
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='unknown' THEN 1 ELSE 0 END) AS unknown,
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
WHERE d.deleted_at IS NULL
"""
def _zahl(anzahl: int, singular: str, plural: str) -> str:
"""Zahl mit passendem Wort. "1 Clients" liest man jeden Tag einmal zu oft."""
return f"{BOLD}{anzahl}{RESET} {singular if anzahl == 1 else plural}"
def _oeffnen(root: Path) -> sqlite3.Connection:
pfad = root / "data" / "app.db"
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True, timeout=1.0)
conn.row_factory = sqlite3.Row
return conn
def _flotte(conn: sqlite3.Connection) -> str:
row = conn.execute(DASHBOARD_COUNT_SQL).fetchone()
online = int(row["online"] or 0)
offline = int(row["offline"] or 0)
teile = [
_zahl(online, "Client online", "Clients online"),
f"{BOLD}{offline}{RESET} nicht erreichbar",
]
# Nur nennen, wenn es sie gibt: eine Null bei "startet neu" ist keine
# Auskunft, sondern Fuellmaterial.
restarting = int(row["restarting"] or 0)
if restarting:
teile.append(_zahl(restarting, "startet neu", "starten neu"))
unbekannt = int(row["unknown"] or 0)
if unbekannt:
teile.append(f"{DIM}{unbekannt} ohne Pruefung{RESET}")
return " · ".join(teile)
def _dhcp() -> str:
"""Aktive Leases aus der Kea-CSV. Laeuft als root, braucht keinen Helfer."""
datei = Path(LEASE_FILE)
if not datei.is_file():
return "keine Lease-Datei"
try:
zeilen = datei.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return "Lease-Datei nicht lesbar"
if len(zeilen) < 2:
return "0 Leases aktiv"
kopf = [name.strip().strip('"') for name in zeilen[0].split(",")]
try:
i_adresse = kopf.index("address")
i_ablauf = kopf.index("expire")
i_zustand = kopf.index("state")
except ValueError:
return "Lease-Datei mit unerwarteten Spalten"
jetzt = int(time.time())
letzte: dict[str, tuple[int, str]] = {}
for zeile in zeilen[1:]:
felder = [feld.strip().strip('"') for feld in zeile.split(",")]
if len(felder) < len(kopf):
continue
try:
letzte[felder[i_adresse]] = (int(felder[i_ablauf] or 0), felder[i_zustand] or "0")
except ValueError:
continue
aktiv = sum(1 for ablauf, zustand in letzte.values() if zustand == "0" and ablauf > jetzt)
return f"{aktiv} {'Lease' if aktiv == 1 else 'Leases'} aktiv"
def _lizenzen(conn: sqlite3.Connection) -> str:
kunden = int(conn.execute("SELECT COUNT(*) FROM license_customers").fetchone()[0] or 0)
lizenzen = int(
conn.execute(
"SELECT COUNT(*) FROM licenses WHERE status IN ('issued','active')"
).fetchone()[0]
or 0
)
return (
_zahl(kunden, "Kunde", "Kunden")
+ " · "
+ _zahl(lizenzen, "Lizenz ausgestellt", "Lizenzen ausgestellt")
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="tesm-motd", description="Banner fuer die SSH-Anmeldung")
parser.add_argument("--app", required=True, choices=sorted(TITEL))
parser.add_argument("--root", required=True, help="Verzeichnis der Installation")
parser.add_argument("--no-color", action="store_true")
args = parser.parse_args(argv)
if args.no_color:
globals().update(AMBER="", DIM="", BOLD="", RESET="")
root = Path(args.root)
# 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.
print(f"{BOLD}{AMBER}TESM{RESET} · {TITEL[args.app]}")
if TAGLINE[args.app]:
print(f"{DIM}{TAGLINE[args.app]}{RESET}")
print()
try:
conn = _oeffnen(root)
except sqlite3.Error:
print(f"{DIM}Datenbank nicht lesbar: {root}{RESET}")
print()
return 0
try:
if args.app == "tesm":
print(f"{_flotte(conn)} · DHCP: {_dhcp()}")
else:
print(_lizenzen(conn))
except sqlite3.Error as fehler:
print(f"{DIM}Zustand nicht verfuegbar ({fehler}){RESET}")
finally:
conn.close()
print()
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except SystemExit:
raise
except Exception: # noqa: BLE001 - ein Banner darf keinen Login stoeren
sys.exit(0)
+77
View File
@@ -251,6 +251,83 @@ würde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
---
## 4a. Anmeldebanner (MOTD)
`install.sh` legt `/etc/update-motd.d/50-<name>` an. Bei jeder SSH-Anmeldung
steht dann der Zustand der Installation im Login-Banner:
```
TESM · TimEShepManager
UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)
3 Clients online · 0 nicht erreichbar · DHCP: 2 Leases aktiv
```
Das Skript liegt unter `/usr/local/lib/tesm/tesm-motd` und benutzt **nur die
Standardbibliothek**: kein venv, kein Import der Anwendung, die Datenbank wird
nur lesend geöffnet. Ein Lauf dauert Millisekunden. Der Vorgänger importierte
dafür die ganze Flask-Anwendung -- bei jeder Anmeldung, und nach einem Wechsel
des Quellverzeichnisses zeigte er stillschweigend die Zahlen einer Datenbank,
die niemand mehr füllte.
Von Hand prüfen:
```bash
sudo /usr/local/lib/tesm/tesm-motd --app tesm --root /srv/tesm-opus
```
Der Aufruf im Banner ist mit `timeout 3` und `|| true` abgesichert: ein
hängender oder fehlschlagender Bannerlauf darf eine Anmeldung nie blockieren.
Ein Banner der Vorgängerfassung (`50-tesm`, das `motd_banner.py` aufruft) wird
entfernt -- nur dieses, nichts anderes in dem Verzeichnis.
---
## 4b. Installation umbenennen
Aus einer Instanz die Hauptinstallation machen (oder umgekehrt):
```bash
sudo bash deploy/rename.sh --app tesm --from tesm-opus --to tesm
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus --to tesm-license
```
Der Name steckt an mehr Stellen als im Verzeichnisnamen, und drei davon sind
leicht zu übersehen:
* **`SITE_KEY`** in der Umgebungsdatei -- daraus entsteht der Name des
Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
Instanz.
* **`web_cert_path`, `web_key_path`, `web_acme_webroot`** in der Datenbank. Sie
enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx nach
dem Umzug nicht mehr.
* **`server_name`** der nginx-Site. `install.sh` schreibt die Datei aus seiner
Vorlage neu, sobald sich die `alias`-Pfade ändern -- und die ändern sich hier
zwangsläufig. Ohne den Namen aus der Datenbank stünden danach zwei Sites mit
`server_name _` auf Port 80, und die erste gewinnt.
Ausserdem wird das **venv verworfen und neu gebaut**: seine Konsolenskripte
tragen den absoluten Pfad im Shebang, und nach dem Umzug zeigt
`venv/bin/gunicorn` auf einen Interpreter, den es nicht mehr gibt. systemd
meldet dann `No such file or directory` -- für eine Datei, die sichtbar da ist.
Belegt der Zielname noch eine Installation der Fassung 1.x, wandert **alles**,
was ihn belegt, in ein Archiv `/srv/<ziel>-sonnet5`: das Verzeichnis, die
Protokolle unter `/var/log/<ziel>`, der ACME-Pfad, die logrotate-Datei, und die
systemd-Units samt nginx-Site unter `systemd-alt/`. Gelöscht wird nichts.
Ports und HTTPS bleiben, wie sie waren; ein vorhandenes Zertifikat zieht mit
und wird weiterverwendet. **Alle angemeldeten Benutzer werden abgemeldet** --
das Sitzungscookie heisst danach anders.
Danach ist auch das Aktualisieren einfacher, weil der Instanzname wegfällt:
```bash
sudo bash /srv/tesm/deploy/update.sh tesm
```
---
## 5. Dienste
```bash
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.0.3"
version = "2.0.6"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -134,7 +134,7 @@ def create_base_app(
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
#: Funktionsliste von TESM waere dort schlicht falsch.
auth_tagline: str = "",
app_version: str = "2.0.3",
app_version: str = "2.0.6",
built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True,
template_folder: str | None = None,
@@ -259,6 +259,17 @@ def create_base_app(
@app.after_request
def _finish_request(response): # type: ignore[no-untyped-def]
# Ein Aktualisierungsdurchlauf, der mit Sitzungscookie kommt und
# unangemeldet endet, hat gerade seine Sitzung verloren. Bei einer
# geschuetzten Seite sagt das schon der Status (401); bei einer, die
# auch oeffentlich antwortet -- die Statusuebersicht etwa -- kommt eine
# 200 mit den oeffentlichen Kacheln zurueck, und der offene Tab merkt
# nichts: er zeigt weiter eine Oberflaeche, als sei alles in Ordnung.
# Deshalb ein Kopf, den der Browser unabhaengig vom Status auswerten
# kann.
if session_store.is_automatic(request) and not current_user.is_authenticated:
if request.cookies.get(app.config["SESSION_COOKIE_NAME"], ""):
response.headers["X-Session-Expired"] = "1"
hsts = ""
if config.security.hsts_enabled:
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
@@ -27,6 +27,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any
from flask import Flask, Request, Response
from flask import request as aktuelle_anfrage
from flask.sessions import SessionInterface, SessionMixin
from werkzeug.datastructures import CallbackDict
@@ -49,9 +50,63 @@ SESSION_ID_BYTES = 32
AUTO_REFRESH_HEADER = "X-Auto-Refresh"
#: Marke auf der Ansichtsfunktion. Steht an der Route, damit man sie dort sieht,
#: wo sie gilt.
FRAGMENT_ATTRIBUT = "_tesm_fragment"
def fragment(view: Any) -> Any:
"""Kennzeichnet eine Route als Aktualisierungsausschnitt.
Solche Routen ruft ein Zeitgeber auf, kein Mensch. Sie duerfen die Sitzung
benutzen, halten sie aber nicht am Leben -- sonst bleibt jeder offene Tab
unbegrenzt angemeldet.
Der Kopf ``X-Auto-Refresh`` allein genuegt dafuer nicht: er setzt voraus,
dass der Browser das aktuelle Skript faehrt. Ein Tab, der seit Stunden
offen ist, faehrt das alte -- und das ist genau der Tab, um den es geht.
"""
setattr(view, FRAGMENT_ATTRIBUT, True)
return view
def _ist_fragment_route() -> bool:
"""Gehoert die laufende Anfrage zu einer als Ausschnitt markierten Route?
``request.endpoint`` steht hier nicht immer schon: Flask oeffnet die Sitzung
**vor** dem Routing (``RequestContext.push`` ruft ``open_session`` und erst
danach ``match_request``). In ``save_session`` ist der Endpunkt gesetzt, in
``open_session`` nicht -- und ohne diesen Nachtrag griff die Erkennung
genau dort nicht, wo sie gebraucht wird.
"""
from flask import current_app
try:
endpunkt = getattr(aktuelle_anfrage, "endpoint", None)
if not endpunkt:
# Selbst nachsehen. Ein zweiter Routenabgleich je Anfrage ist
# billiger als eine Abmeldung, die nicht stattfindet.
adapter = current_app.url_map.bind_to_environ(aktuelle_anfrage.environ)
endpunkt, _ = adapter.match(
aktuelle_anfrage.path, method=aktuelle_anfrage.method
)
ansicht = current_app.view_functions.get(str(endpunkt))
except Exception: # noqa: BLE001 - keine Route, Umleitung, kein App-Kontext
return False
return bool(getattr(ansicht, FRAGMENT_ATTRIBUT, False))
def is_automatic(request: Request) -> bool:
"""Kam die Anfrage von einem Zeitgeber statt von einem Menschen?"""
return request.headers.get(AUTO_REFRESH_HEADER, "") == "1"
"""Kam die Anfrage von einem Zeitgeber statt von einem Menschen?
Zwei Kennzeichen, und eines genuegt: der Kopf, den das Skript setzt,
und die Route selbst. Das zweite traegt auch dann, wenn ein lange
offener Tab noch ein aelteres Skript faehrt -- und das ist der Tab,
um den es geht.
"""
if request.headers.get(AUTO_REFRESH_HEADER, "") == "1":
return True
return _ist_fragment_route()
def _now() -> datetime:
@@ -239,6 +294,31 @@ class SqliteSessionInterface(SessionInterface):
),
)
session.new = False
# ``save_session`` bekommt die Anfrage nicht als Argument -- anders als
# ``open_session``, wo ein Parameter denselben Namen traegt. Hier ist es
# der Anfragekontext von Flask; die Antwort wird darin erzeugt.
elif is_automatic(aktuelle_anfrage):
# Die zweite Schreibstelle, und die eigentliche Ursache dafuer,
# dass die Abmeldung nie ausgeloest hat: hier wurde das
# Leerlauffenster bei **jeder** Anfrage verlaengert, die die
# Sitzung anfasst. Die Pruefung in open_session lief damit ins
# Leere -- ein Aktualisierungsdurchlauf einer Kachel fasst die
# Sitzung an (Flash-Speicher, CSRF), und schon stand das Fenster
# wieder 30 Minuten offen. Auf dem Lizenzserver fiel es nicht
# auf: dort pollt keine Kachel.
#
# Geaenderte Sitzungsdaten muessen trotzdem erhalten bleiben --
# sie zu verwerfen waere eine zweite Fehlerquelle. Nur die
# Fristen bleiben stehen.
conn.execute(
"UPDATE user_sessions SET user_id=?, data=?, reauth_at=? WHERE id=?",
(
session.get("user_id"),
payload,
session.get("_reauth_at"),
session.sid,
),
)
else:
conn.execute(
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
@@ -247,7 +327,11 @@ class SqliteSessionInterface(SessionInterface):
session.get("user_id"),
payload,
_iso(now),
_iso(now + timedelta(minutes=self.idle_minutes)),
# ``_idle(conn)`` und nicht ``idle_minutes``: die Zeit ist
# zur Laufzeit einstellbar, und diese Stelle hat die
# Einstellung ignoriert. Bei der Vorgabe von 30 Minuten
# fiel das nicht auf -- bei einer geaenderten schon.
_iso(now + timedelta(minutes=self._idle(conn))),
session.get("_reauth_at"),
session.sid,
),
@@ -66,6 +66,15 @@ export async function api(url, options = {}) {
...(options.headers || {}),
};
const response = await fetch(url, opts);
// Eine Seite, die auch oeffentlich antwortet -- die Statusuebersicht etwa --
// liefert nach dem Ablauf der Sitzung eine 200 mit den oeffentlichen
// Kacheln. Am Status ist der Verlust also nicht zu erkennen; an diesem Kopf
// schon. Behandelt wird er wie eine 401.
if (response.headers.get("X-Session-Expired") === "1") {
const abgelaufen = new Error("Die Sitzung ist abgelaufen.");
abgelaufen.status = 401;
throw abgelaufen;
}
const type = response.headers.get("content-type") || "";
const payload = type.includes("application/json") ? await response.json() : await response.text();
if (!response.ok) {
@@ -24,6 +24,7 @@ from .. import audit
from ..auth.models import current_user, permission_required
from ..auth.service import client_ip
from ..extension import core, db, transaction
from ..security.sessions import fragment
bp = Blueprint("logs", __name__, url_prefix="/protokolle")
@@ -53,6 +54,7 @@ def live():
@bp.get("/laufend/inhalt")
@permission_required("logs_live.view")
@fragment
def live_fragment():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
needle = (request.args.get("q") or "").strip()
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing-server"
version = "2.0.3"
version = "2.0.6"
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
requires-python = ">=3.11"
dependencies = ["tesm-licensing==2.0.3", "cryptography>=41"]
dependencies = ["tesm-licensing==2.0.6", "cryptography>=41"]
[tool.setuptools.packages.find]
where = ["src"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing"
version = "2.0.3"
version = "2.0.6"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = ["cryptography>=42"]