Compare commits

...
3 Commits
Author SHA1 Message Date
alientimandClaude Opus 5 635722089e 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:40 +02:00
alientimandClaude Opus 5 6dff1ed137 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:32 +02:00
alientimandClaude Opus 5 b4529a0e31 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:21 +02:00
9 changed files with 675 additions and 10 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.0.4"
version = "2.0.6"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+11 -6
View File
@@ -636,10 +636,12 @@ def next_check_at(conn: sqlite3.Connection, interval_seconds: int) -> datetime |
return stamp + timedelta(seconds=max(1, int(interval_seconds)))
def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
row = Database.one(
conn,
"""
#: Zaehlung fuer die Statusuebersicht. Steht als Konstante, weil das
#: Anmeldebanner (``deploy/tesm-motd``) dieselben Zahlen zeigt und ohne
#: Anwendung laeuft -- es kann diese Datei nicht importieren, also vergleicht
#: ein Hygienetest die beiden Zeichenketten. Zwei Wahrheiten ueber "online"
#: waeren schlimmer als eine doppelte Zeichenkette.
DASHBOARD_COUNT_SQL = """
SELECT
COUNT(*) AS total,
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
@@ -649,8 +651,11 @@ def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
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 dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
row = Database.one(conn, DASHBOARD_COUNT_SQL)
row = row or {}
return {
key: int(row.get(key) or 0)
+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.4"
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.4",
app_version: str = "2.0.6",
built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True,
template_folder: str | None = None,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing"
version = "2.0.4"
version = "2.0.6"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = ["cryptography>=42"]