Compare commits

..
8 Commits
Author SHA1 Message Date
alientim 9b3051c984 VERSION: 1.0.1
Fortlaufendes Release nach v1.0.0 -- Schema unveraendert (SCHEMA_VERSION
weiterhin 1), bereits installierte v1.0.0-Hosts aktualisieren sich beim
naechsten update.sh-Lauf per In-Place-Update (Datenbank bleibt erhalten).
2026-08-14 01:06:22 +02:00
alientim 6dd412c6e1 Import: 'database is locked' bei LDAP-/DHCP-Import behoben; Wartungs-Update mit Einzelschritten + Fehler-Log
_import_ldap()/_import_dhcp() riefen bislang set_setting() auf, das eine
EIGENE sqlite3-Verbindung oeffnet. import_apply() ruft mehrere
IMPORT_APPLIERS nacheinander auf DERSELBEN, noch offenen Verbindung auf
und committet erst am Ende -- war zu diesem Zeitpunkt (z.B. durch eine
vorher im selben Import verarbeitete Kategorie) bereits eine Schreib-
Transaktion offen, blockierte set_settings()' eigene Verbindung darauf,
bis 'sqlite3.OperationalError: database is locked' auftrat (live in
Produktion reproduziert). Beide Funktionen schreiben jetzt direkt auf
die uebergebene Verbindung. Isoliert reproduziert und verifiziert (alter
Code: Lock-Fehler: neuer Code: fehlerfrei).

Wartungs-Update fuer Linux-Clients (_apt_upgrade_command) außerdem
komplett neu aufgebaut: statt einer einzigen, mit '&&' verketteten
Befehlskette laufen jetzt einzeln benannte Schritte (apt update ->
initramfs MODULES=most dauerhaft in initramfs.conf setzen -> apt upgrade
-> apt full-upgrade -> apt clean -> apt autoremove), jeder mit eigenem
'Schritt N (...) -> erfolgreich/fehlgeschlagen (Exit-Code ...)'-Marker im
Live-Fenster (mit Zeitstempel je Zeile, wie gehabt). Bricht beim ersten
fehlgeschlagenen Schritt sofort ab. Bei einem mit Exit-Code
fehlgeschlagenen Update wird zusaetzlich eine Fehler-Logdatei unter
/var/log/tesm/clientupdate/<clientname>.error geschrieben (ueberschreibt
eine vorherige aus einem frueheren Fehlschlag desselben Clients).
2026-08-14 01:00:15 +02:00
alientim 3c3fbd8f23 update.sh: In-Place-Update statt Fresh-Install, wenn sich das DB-Schema nicht geändert hat
Neue Datei SCHEMA_VERSION (neben VERSION) markiert den DB-Schema-Stand.
update.sh laedt/entpackt das Release-Paket jetzt VOR der Bestaetigungs-
abfrage und vergleicht die SCHEMA_VERSION der bestehenden Installation
mit der des neuen Pakets:

- Stimmen beide ueberein (und existiert bereits eine sqlite.db) --
  In-Place-Update: /srv/tesm bleibt bestehen, install.sh rsynct nur den
  neuen Code darueber (sqlite.db/fernet.key/secret.key waren dort schon
  immer von rsync ausgeschlossen). Geraete/Switche/Zugangsdaten/Benutzer/
  Einstellungen bleiben vollstaendig erhalten, kein manuelles Neuanlegen
  oder Re-Import noetig.
- Unterscheiden sie sich (oder ist eine der beiden Versionen unbekannt,
  z.B. eine Installation von vor dieser Aenderung) -- weiterhin der
  bisherige sichere Weg: /srv/tesm wird beiseite verschoben (nicht
  geloescht) und komplett frisch installiert.

/srv/poe_manager (voellig andere App/Schema) wird davon unabhaengig
immer beiseite verschoben, nie in-place uebernommen.

create_db.py bekommt einen Hinweis-Kommentar, SCHEMA_VERSION bei jeder
Schema-Aenderung mit hochzuzaehlen.
2026-08-14 00:20:52 +02:00
alientim d77327cee1 skripts/: Admin-Hilfsskripte für Bulk-Aktionen ins Repo aufgenommen
- assign_credential_to_all_devices.py: weist allen aktiven Geraeten auf
  einen Schlag dieselben Zugangsdaten zu (Name als optionales Argument,
  Standard 'HAPS'), inkl. Eintrag im Aenderungsverlauf.
- accept_all_hostkeys.py: akzeptiert die SSH-Host-Keys aller aktiven
  Geraete in einem Rutsch (paralleler Verbindungsaufbau + Schluesseltausch,
  kein Login) -- nutzt dieselbe known_hosts-Datei wie die App, ein
  geaenderter (statt unbekannter) Key wird bewusst NICHT automatisch
  uebernommen, sondern separat als Warnung ausgegeben.

Beide live gegen POETEST verifiziert.
2026-08-14 00:13:11 +02:00
alientim d72db26a86 Versionsnummer in der UI anzeigen (Login-Seite + Sidebar-Footer)
VERSION-Datei (neben app.py, wird von install.sh 1:1 nach /srv/tesm
mitkopiert) plus Context-Processor inject_tesm_version() -- die
zugehoerige app.py-Aenderung war bereits im vorherigen Commit
enthalten. Damit laesst sich der installierte Stand direkt in der
Oberflaeche ablesen, ohne Datei-Zeitstempel oder Commit-Hashes
vergleichen zu muessen.
2026-08-13 23:56:32 +02:00
alientim 0b9b92a228 Wartung/Update: MODULES=most beim apt upgrade + autoremove/clean danach
_apt_upgrade_command() setzt jetzt zusaetzlich MODULES=most (initramfs-
tools) fuer den Update-Aufruf -- falls dabei ein neuer Kernel installiert
und ein initramfs neu gebaut wird, landen vorsorglich ALLE verfuegbaren
Treiber darin statt nur die per Abhaengigkeitserkennung ('dep') erkannten,
damit der Client nach einem Kernel-Wechsel zuverlaessig bootet. Am Ende
zusaetzlich 'apt-get autoremove'/'apt-get clean', damit nicht mehr
benoetigte Kernel/Pakete sowie der .deb-Cache nicht auf jedem Client
unbegrenzt Platz belegen. Live gegen einen echten Ubuntu-Client
verifiziert (Exit-Code 0, komplette Kette lief durch).
2026-08-13 23:55:56 +02:00
alientim 2a092af508 Merge: poe.sh-Revert von origin/main mit lokaler Kommentar-Aktualisierung zusammenführen 2026-08-13 23:26:39 +02:00
alientim c55f9d992d poe.sh: Regression bei ArubaOS-Manager-Accounts zurückgesetzt
disable_poe()/enable_poe() waren waehrend dieser Sitzung um explizite
'timeout { exit 1 }'/'eof { exit 1 }'-Abbrueche 'gehaertet' worden, um
echte SSH-/Kommando-Fehlschlaege vom Aufrufer unterscheidbar zu machen.

Live an Switch 'ST-2000-Sued' (Aruba JL322A 2930M) reproduziert: das
dafuer verwendete Login-Prompt-Pattern -re ".*> $" matcht nur einen
Operator-Prompt ("switchname>"). Landet der SSH-Account (hier: admin)
nach dem Login mit Manager-Rechten direkt im privilegierten Modus
("switchname#"), matcht das Pattern nie -> 5s-Timeout -> exit 1,
obwohl Passwort und Verbindung tatsaechlich in Ordnung waren. Jeder
manuelle wie automatische PoE-Neustart ueber einen Manager-Account
schlug dadurch fehl.

disable_poe()/enable_poe() sind jetzt wieder exakt auf den Stand der
urspruenglichen, in Produktion ueber Jahre bewaehrten Fassung
zurueckgesetzt (keine expliziten timeout/eof-Abbrueche, kein
abschliessendes exit 0) -- nur der Kommentar wurde aktualisiert, um
diesen Vorfall zu dokumentieren.
2026-08-13 23:25:55 +02:00
10 changed files with 425 additions and 91 deletions

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Akzeptiert die SSH-Host-Keys aller aktiven Geraete (Clients) in einem
Rutsch -- macht technisch dasselbe wie "Verbindung testen" im Browser-
Terminal (Verbindungsaufbau -> Host-Key abfragen -> "yes" -> trennen),
nur automatisiert fuer alle Geraete statt eines einzelnen.
Nutzt bewusst dieselbe known_hosts-Datei/dasselbe Format wie die App
(SSH_KNOWN_HOSTS_PATH in app.py) -- Geraete, deren Key hier akzeptiert
wird, muessen danach in der Weboberflaeche NICHT mehr einzeln bestaetigt
werden (SSH-Verbindungstest und Wartungs-Aktionen teilen sich dieselbe
Datei).
Aufruf auf dem Server: sudo /srv/tesm/venv/bin/python3 accept_all_hostkeys.py
"""
import socket
import sqlite3
from concurrent.futures import ThreadPoolExecutor, as_completed
import paramiko
DB_PATH = "/srv/tesm/sqlite.db"
KNOWN_HOSTS_PATH = "/srv/tesm/known_hosts"
DEFAULT_SSH_PORT = 22
CONNECT_TIMEOUT = 4
MAX_WORKERS = 32
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
devices = conn.execute(
"SELECT name, ip, ssh_port FROM devices WHERE deleted_at IS NULL ORDER BY name"
).fetchall()
conn.close()
def fetch_key(dev):
"""Nur der Handshake (Verbindungsaufbau + Schluesseltausch), KEIN Login
-- exakt der Teil, der im Browser-Terminal vor der Passwortabfrage
passiert. Parallel je Gerät, sonst dauert eine Liste mit vielen
offline Geraeten (je bis zu CONNECT_TIMEOUT Sekunden) unnoetig lange."""
host = dev["ip"]
port = dev["ssh_port"] or DEFAULT_SSH_PORT
try:
sock = socket.create_connection((host, port), timeout=CONNECT_TIMEOUT)
transport = paramiko.Transport(sock)
transport.start_client(timeout=CONNECT_TIMEOUT)
server_key = transport.get_remote_server_key()
transport.close()
return dev, server_key, None
except (OSError, paramiko.SSHException, EOFError) as e:
return dev, None, str(e)
hostkeys = paramiko.HostKeys()
try:
hostkeys.load(KNOWN_HOSTS_PATH)
except (OSError, paramiko.SSHException):
pass
accepted, already_known, changed, unreachable = [], [], [], []
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
futures = [pool.submit(fetch_key, dev) for dev in devices]
for future in as_completed(futures):
dev, server_key, err = future.result()
host = dev["ip"]
port = dev["ssh_port"] or DEFAULT_SSH_PORT
lookup_host = host if port == DEFAULT_SSH_PORT else f"[{host}]:{port}"
if err is not None:
unreachable.append((dev["name"], host, err))
continue
existing = hostkeys.lookup(lookup_host)
if existing and existing.get(server_key.get_name()):
if existing[server_key.get_name()] == server_key:
already_known.append((dev["name"], host))
continue
else:
# Nicht blind ueberschreiben -- das waere genau das MITM-
# Szenario, vor dem die App im Terminal auch schuetzt (harte
# Ablehnung statt automatischer Uebernahme eines GEAENDERTEN
# Keys).
changed.append((dev["name"], host))
continue
hostkeys.add(lookup_host, server_key.get_name(), server_key)
accepted.append((dev["name"], host))
hostkeys.save(KNOWN_HOSTS_PATH)
print(f"Neu akzeptiert: {len(accepted)}")
for name, host in accepted:
print(f" + {name} ({host})")
print(f"Bereits bekannt: {len(already_known)}")
if changed:
print(f"ACHTUNG -- Host-Key geaendert, NICHT automatisch uebernommen ({len(changed)}):")
for name, host in changed:
print(f" ! {name} ({host}) -- manuell in der App pruefen (moeglicher MITM oder Geraetetausch)")
print(f"Nicht erreichbar: {len(unreachable)}")
for name, host, err in unreachable:
print(f" - {name} ({host}): {err}")
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Weist allen aktiven Geraeten (Clients) auf einen Schlag dieselben
Zugangsdaten zu -- spart das manuelle Durchklicken jedes einzelnen
Geraets in der Weboberflaeche.
Aufruf auf dem Server:
sudo /srv/tesm/venv/bin/python3 assign_credential_to_all_devices.py [NAME]
NAME ist der Name des Zugangsdaten-Eintrags (Standard: "HAPS")."""
import sqlite3
import sys
from datetime import datetime
DB_PATH = "/srv/tesm/sqlite.db"
CRED_NAME = sys.argv[1] if len(sys.argv) > 1 else "HAPS"
conn = sqlite3.connect(DB_PATH)
cred = conn.execute(
"SELECT id FROM credentials WHERE name = ? AND deleted_at IS NULL", (CRED_NAME,)
).fetchone()
if not cred:
print(f"Fehler: Zugangsdaten '{CRED_NAME}' nicht gefunden (oder im Papierkorb).")
raise SystemExit(1)
credential_id = cred[0]
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
cur = conn.execute(
"UPDATE devices SET credential_id=?, last_modified_by=?, last_modified_at=? WHERE deleted_at IS NULL",
(credential_id, "Admin (Bulk)", now),
)
count = cur.rowcount
conn.execute(
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
(now, "system", "devices.bulk_credential", CRED_NAME, f"{count} Geräte auf Zugangsdaten '{CRED_NAME}' gesetzt"),
)
conn.commit()
conn.close()
print(f"{count} Geräte auf Zugangsdaten '{CRED_NAME}' (id={credential_id}) gesetzt.")
+1
View File
@@ -0,0 +1 @@
1
+1
View File
@@ -0,0 +1 @@
1.0.1
+121 -9
View File
@@ -509,6 +509,30 @@ def inject_current_year():
return {"current_year": datetime.now().year} return {"current_year": datetime.now().year}
def _read_tesm_version():
"""Liest die Versionsnummer aus VERSION (im Repo neben app.py, wird von
install.sh 1:1 nach /srv/tesm mitkopiert). Fehlt die Datei (z.B. lokale
Entwicklung ohne vollständigen Checkout), lieber "unbekannt" anzeigen
als die App deswegen abstürzen zu lassen."""
try:
with open(os.path.join(BASE_DIR, "VERSION"), "r", encoding="utf-8") as f:
return f.read().strip() or "unbekannt"
except OSError:
return "unbekannt"
TESM_VERSION = _read_tesm_version()
@app.context_processor
def inject_tesm_version():
"""Für die Versionsanzeige im Sidebar-Footer (siehe base.html) — beim
Modulimport einmalig gelesen (Neustart des Dienstes nach einem Update
erforderlich, damit eine neue Versionsnummer sichtbar wird — geschieht
ohnehin bei jedem install.sh/update.sh-Lauf)."""
return {"tesm_version": TESM_VERSION}
@app.context_processor @app.context_processor
def inject_nav(): def inject_nav():
if not current_user.is_authenticated: if not current_user.is_authenticated:
@@ -4424,9 +4448,22 @@ def _import_groups(conn, items):
def _import_ldap(conn, data): def _import_ldap(conn, data):
# set_setting() bewusst NICHT verwendet: das öffnet eine EIGENE
# sqlite3-Verbindung -- import_apply() ruft mehrere IMPORT_APPLIERS
# nacheinander auf DERSELBEN, bereits offenen "conn" auf und committet
# erst ganz am Ende. Ist zu diesem Zeitpunkt (z.B. durch eine vorher im
# selben Import verarbeitete Kategorie) bereits eine Schreib-Transaktion
# auf "conn" offen, blockiert set_settings()' eigene Verbindung darauf,
# bis der SQLite-Standard-Timeout zuschlägt -- "sqlite3.OperationalError:
# database is locked", live in Produktion reproduziert. Direkt auf der
# übergebenen "conn" schreiben vermeidet die zweite Verbindung komplett.
for key, value in data.get("settings", {}).items(): for key, value in data.get("settings", {}).items():
if key in LDAP_SETTING_KEYS: if key in LDAP_SETTING_KEYS:
set_setting(key, value) conn.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
if data.get("bind_username"): if data.get("bind_username"):
conn.execute( conn.execute(
"INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) " "INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) "
@@ -4445,9 +4482,16 @@ def _import_ldap(conn, data):
def _import_dhcp(conn, data): def _import_dhcp(conn, data):
# set_setting() bewusst NICHT verwendet -- siehe ausführlicher Kommentar
# in _import_ldap() (dieselbe "database is locked"-Falle bei mehreren
# IMPORT_APPLIERS auf derselben offenen Transaktion).
for key, value in data.get("settings", {}).items(): for key, value in data.get("settings", {}).items():
if key in DHCP_DEFAULTS: if key in DHCP_DEFAULTS:
set_setting(key, value) conn.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
n = 0 n = 0
# Subnetze additiv (nie löschen/ersetzen) -- ein bestehendes, unabhängig # Subnetze additiv (nie löschen/ersetzen) -- ein bestehendes, unabhängig
# vom Ziel-Admin konfiguriertes Subnetz soll ein Import nie entfernen. # vom Ziel-Admin konfiguriertes Subnetz soll ein Import nie entfernen.
@@ -5572,6 +5616,27 @@ def maintenance_status():
_MAINTENANCE_OUTPUT_MAX_LINES = 400 _MAINTENANCE_OUTPUT_MAX_LINES = 400
TESM_CLIENT_UPDATE_LOG_DIR = os.path.join(TESM_LOG_DIR, "clientupdate")
def _write_client_update_error_log(name, exit_code, lines):
"""Schreibt bei einem mit Exit-Code fehlgeschlagenen Wartungs-Update
eine eigene Fehler-Logdatei pro Client (überschreibt eine vorherige aus
einem früheren Fehlschlag desselben Clients) -- zusätzlich zur ohnehin
im Job-Status/Live-Fenster sichtbaren Ausgabe, damit ein Fehlschlag
auch ohne offenes Browser-Fenster im Nachhinein auffindbar bleibt."""
try:
os.makedirs(TESM_CLIENT_UPDATE_LOG_DIR, exist_ok=True)
os.chmod(TESM_CLIENT_UPDATE_LOG_DIR, 0o777)
safe_name = secure_filename(name) or "unbenannt"
path = os.path.join(TESM_CLIENT_UPDATE_LOG_DIR, f"{safe_name}.error")
with open(path, "w", encoding="utf-8") as f:
f.write(f"Update fehlgeschlagen: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"Exit-Code: {exit_code}\n\n")
f.write("\n".join(lines))
f.write("\n")
except OSError:
app.logger.error("_write_client_update_error_log failed:\n%s", traceback.format_exc())
def _maintenance_run_update(mac, name, host, port, username, password): def _maintenance_run_update(mac, name, host, port, username, password):
@@ -5623,6 +5688,7 @@ def _maintenance_run_update(mac, name, host, port, username, password):
message=f"Update fehlgeschlagen (Exit-Code {result['exit_code']}).", message=f"Update fehlgeschlagen (Exit-Code {result['exit_code']}).",
output="\n".join(lines), finished=finished, output="\n".join(lines), finished=finished,
) )
_write_client_update_error_log(name, result["exit_code"], lines)
log_action_system("maintenance.update", name, f"fehlgeschlagen: Exit-Code {result['exit_code']}") log_action_system("maintenance.update", name, f"fehlgeschlagen: Exit-Code {result['exit_code']}")
@@ -6084,18 +6150,64 @@ def _ssh_terminal_inner(ws):
# ihn möglich sind -- beide Wege teilen sich dieselbe SSH_KNOWN_HOSTS_PATH. # ihn möglich sind -- beide Wege teilen sich dieselbe SSH_KNOWN_HOSTS_PATH.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Jeder Schritt einzeln benannt (statt nur mit "&&" verkettet), damit nach
# jedem Schritt ein eigener "Schritt N -> erfolgreich/fehlgeschlagen"-Marker
# ins Live-Log geschrieben werden kann (siehe _apt_upgrade_command()) --
# apt/apt-get statt der literal vorgegebenen "apt"-Aufrufe, weil apt-get
# eine stabile, für Skripte gedachte Schnittstelle hat (die apt(8)-Manpage
# warnt ausdrücklich: "the apt command is intended to be pleasant for
# end users and does not need to be backward compatible" -- also nichts
# für Automatisierung).
_LINUX_UPDATE_STEPS = [
("apt update", "apt-get update"),
("initramfs MODULES=most setzen", "sed -i 's/^MODULES=.*/MODULES=most/' /etc/initramfs-tools/initramfs.conf"),
(
"apt upgrade",
'apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade',
),
(
"apt full-upgrade",
'apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" full-upgrade',
),
("apt clean", "apt-get clean"),
("apt autoremove", "apt-get -y autoremove"),
]
def _apt_upgrade_command(): def _apt_upgrade_command():
"""Sicheres, nicht-interaktives 'apt update && apt upgrade -y': """Baut die Wartungs-Update-Kette aus _LINUX_UPDATE_STEPS zusammen --
jeder Schritt einzeln mit eigenem "=== Schritt N/M: ... ===" und
danach "Schritt N (...) -> erfolgreich"/"-> fehlgeschlagen (Exit-Code
...)" im Live-Log, statt nur einer einzigen Erfolgs-/Fehlermeldung für
die ganze Kette. Bricht beim ersten fehlgeschlagenen Schritt sofort ab
(dessen Exit-Code wird zum Exit-Code des Gesamtkommandos), statt
blind mit dem nächsten Schritt weiterzumachen.
DEBIAN_FRONTEND=noninteractive plus --force-confdef/--force-confold DEBIAN_FRONTEND=noninteractive plus --force-confdef/--force-confold
verhindern, dass ein Paket-Postinst-Skript auf eine Konfig-Datei- verhindern, dass ein Paket-Postinst-Skript auf eine Konfig-Datei-
Rückfrage wartet, die nie kommt (das Kommando würde sonst ohne PTY Rückfrage wartet, die nie kommt (das Kommando würde sonst ohne PTY
unbeaufsichtigt hängen bleiben, bis der Timeout zuschlägt).""" unbeaufsichtigt hängen bleiben, bis der Timeout zuschlägt).
inner = (
"env DEBIAN_FRONTEND=noninteractive apt-get update && " MODULES=most wird -- anders als zuvor -- dauerhaft in
'env DEBIAN_FRONTEND=noninteractive apt-get -y ' /etc/initramfs-tools/initramfs.conf gesetzt (nicht nur für diesen einen
'-o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade' Aufruf per Umgebungsvariable): falls apt upgrade/full-upgrade einen
neuen Kernel installiert und dabei ein initramfs neu gebaut wird,
landen so ALLE verfügbaren Treiber darin (statt nur die per "dep"
erkannten), damit das Gerät nach einem Kernel-Wechsel zuverlässig
bootet -- und das bleibt auch für jeden künftigen, unabhängig von
dieser App ausgelösten Kernel-Update/initramfs-Rebuild so bestehen."""
script_lines = ["export DEBIAN_FRONTEND=noninteractive"]
total = len(_LINUX_UPDATE_STEPS)
for i, (label, cmd) in enumerate(_LINUX_UPDATE_STEPS, start=1):
script_lines.append(f"echo '=== Schritt {i}/{total}: {label} ==='")
script_lines.append(cmd)
script_lines.append("_rc=$?")
script_lines.append(
f'if [ "$_rc" -eq 0 ]; then echo "Schritt {i} ({label}) -> erfolgreich"; '
f'else echo "Schritt {i} ({label}) -> fehlgeschlagen (Exit-Code $_rc)"; exit "$_rc"; fi'
) )
return f"sudo -S -p '' bash -c {shlex.quote(inner)}" script = "\n".join(script_lines)
return f"sudo -S -p '' bash -c {shlex.quote(script)}"
def _resolve_device_ssh_target(conn, device): def _resolve_device_ssh_target(conn, device):
+12 -1
View File
@@ -1,5 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Initialisiert die SQLite-Datenbank für TESM.""" """Initialisiert die SQLite-Datenbank für TESM.
WICHTIG: Wer hier eine neue Tabelle oder Spalte hinzufügt, muss auch die
Datei SCHEMA_VERSION (daneben, im selben Verzeichnis) um 1 erhöhen.
update.sh vergleicht diese Zahl zwischen der aktuell installierten und der
neuen Version, um zu entscheiden, ob ein Update in-place laufen darf
(bestehende Datenbank bleibt erhalten) oder eine frische Installation
nötig ist (Datenbank wird beiseitegesichert). Ein vergessenes Hochzählen
würde dazu führen, dass ein Host mit altem Schema faelschlich per
In-Place-Update aktualisiert wird, statt frisch installiert zu werden --
_ensure_schema() faengt die meisten Faelle zwar zur Laufzeit ab, ist aber
kein Ersatz fuer eine korrekte Versionsnummer hier."""
import os import os
import sqlite3 import sqlite3
+1 -1
View File
@@ -90,7 +90,7 @@
</a> </a>
</div> </div>
<div class="sidebar-copyright" style="padding:10px 20px 4px; font-size:10.5px; color:var(--text-faint); text-align:center;"> <div class="sidebar-copyright" style="padding:10px 20px 4px; font-size:10.5px; color:var(--text-faint); text-align:center;">
© {{ current_year }} TESM — TimEShepManager © {{ current_year }} TESM — TimEShepManager v{{ tesm_version }}
</div> </div>
</div> </div>
</aside> </aside>
+1
View File
@@ -35,6 +35,7 @@
</div> </div>
<button type="submit" class="btn btn-primary btn-block" style="margin-top:6px;">Anmelden</button> <button type="submit" class="btn btn-primary btn-block" style="margin-top:6px;">Anmelden</button>
</form> </form>
<div style="margin-top:16px; font-size:10.5px; color:var(--text-faint); text-align:center;">TESM v{{ tesm_version }}</div>
</div> </div>
</div> </div>
+128 -70
View File
@@ -15,16 +15,23 @@
# Lädt dafür NUR den Release-Tarball per HTTP herunter (kein "git clone", # Lädt dafür NUR den Release-Tarball per HTTP herunter (kein "git clone",
# kein Git auf dem Zielsystem nötig). # kein Git auf dem Zielsystem nötig).
# #
# Alle ALTEN Installationen werden dabei NICHT gelöscht, sondern nur # /srv/poe_manager (Stufe 2, komplett anderes Schema/App) wird IMMER nur
# gestoppt/deaktiviert und (falls vorhanden) beiseite verschoben (siehe # gestoppt/deaktiviert und beiseite verschoben, nie in-place übernommen.
# OLD_DIRS/BACKED_UP_DIRS) -- als Rückfalloption UND als Datenquelle für
# einen späteren Export/Import.
# #
# WICHTIG: Das Ergebnis ist eine FRISCHE Installation OHNE die Daten der # /srv/tesm (Stufe 3) dagegen wird per SCHEMA_VERSION-Datei verglichen:
# alten Instanz (Geräte, Switche, Zugangsdaten, Benutzer, Einstellungen, …). # stimmt die Versionsnummer der bereits installierten mit der des neuen
# Vor dem Ausführen unbedingt unter Einstellungen -> Im-/Export ein Backup # Release-Pakets überein, hat sich am Datenbank-Schema nichts geändert --
# der alten Instanz exportieren -- danach entweder alles neu einrichten oder # dann läuft install.sh IN-PLACE gegen das bestehende /srv/tesm (dessen
# dieses Backup in die neue Installation importieren. # rsync schließt sqlite.db/fernet.key/secret.key ohnehin aus), die
# komplette Datenbank (Geräte, Switche, Zugangsdaten, Benutzer,
# Einstellungen, …) bleibt dabei erhalten. Nur bei einer TATSÄCHLICHEN
# Schema-Änderung (oder wenn die alte Installation noch keine
# SCHEMA_VERSION-Datei kennt, also aus der Zeit vor dieser Prüfung stammt)
# wird wie bisher beiseite verschoben und frisch installiert.
#
# Lädt/entpackt das Release-Paket deshalb VOR der Bestätigungsabfrage --
# nur so lässt sich vorher schon ansagen, ob es sich um ein
# datenerhaltendes Update oder eine frische Installation handelt.
# #
# Auszuführen als root auf dem Zielsystem (z.B. POETEST): # Auszuführen als root auf dem Zielsystem (z.B. POETEST):
# sudo ./update.sh # sudo ./update.sh
@@ -45,10 +52,6 @@ PACKAGE_NAME="tesm-${RELEASE_TAG}.tar.gz"
DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}" DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)" TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
# Alle historischen Installationsorte, die dieser Host je nach Alter tragen
# kann -- siehe Kommentar oben. Jeder, der existiert, wird beiseite
# verschoben statt gelöscht.
OLD_DIRS=("/srv/tesm" "/srv/poe_manager")
WORK_DIR="/tmp/tesm-update-$(date +%s)" WORK_DIR="/tmp/tesm-update-$(date +%s)"
PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}" PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}"
@@ -66,52 +69,6 @@ echo -e "${YELLOW}==============================================================
echo -e "${YELLOW} TESM Update/Reinstall — Release \"${RELEASE_TAG}\"${NC}" echo -e "${YELLOW} TESM Update/Reinstall — Release \"${RELEASE_TAG}\"${NC}"
echo -e "${YELLOW}============================================================================${NC}" echo -e "${YELLOW}============================================================================${NC}"
echo echo
echo -e "${RED}ACHTUNG:${NC} Dies richtet TESM KOMPLETT NEU ein — frische Datenbank, KEINE"
echo "Geräte/Switche/Zugangsdaten/Benutzer/Einstellungen aus der aktuellen Installation."
echo
echo "Alle auf diesem Host ggf. vorhandenen älteren Installationen (/srv/tesm,"
echo "/srv/poe_manager) werden dabei NICHT gelöscht, sondern nach dem Stoppen der"
echo "jeweiligen Dienste nach <Verzeichnis>-backup-${TIMESTAMP} verschoben (bleiben"
echo "vollständig erhalten, inkl. sqlite.db)."
echo
echo "Ebenfalls gestoppt/deaktiviert werden ältere Dienste, falls noch vorhanden:"
echo " - poe_web.service (Flask-App \"PoE Manager\" unter /srv/poe_manager)"
echo " - rpi-check.service / rpi-check-restart.* (das ursprüngliche reine"
echo " Bash-Setup aus dem Repo WiS/Aruba-PoE, ganz ohne Datenbank/Web-UI)"
echo
echo -e "${YELLOW}Falls noch nicht geschehen: JETZT abbrechen und zuerst unter${NC}"
echo -e "${YELLOW}Einstellungen -> Im-/Export ein Backup der aktuellen Daten exportieren!${NC}"
echo "Nach dem Update entweder dieses Backup importieren, oder alles manuell neu anlegen."
echo
read -r -p "Fortfahren? [y/N] " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
echo "Abgebrochen — nichts wurde verändert."
exit 0
fi
echo -e "${RED}→${NC} Stoppe und deaktiviere bestehende Dienste..."
# Alle drei historischen Generationen -- je nachdem, was auf diesem Host
# tatsächlich (noch) existiert. "|| true" macht nichts kaputt, falls
# einzelne Units gar nicht vorhanden sind.
systemctl stop tesm.service tesm-check.service tesm-check-restart.timer 2>/dev/null || true
systemctl disable tesm.service tesm-check.service tesm-check-restart.timer 2>/dev/null || true
systemctl stop poe_web.service 2>/dev/null || true
systemctl disable poe_web.service 2>/dev/null || true
systemctl stop rpi-check.service rpi-check-restart.service rpi-check-restart.timer 2>/dev/null || true
systemctl disable rpi-check.service rpi-check-restart.service rpi-check-restart.timer 2>/dev/null || true
echo -e "${GREEN}✔${NC} Dienste gestoppt und deaktiviert."
BACKED_UP_DIRS=()
for dir in "${OLD_DIRS[@]}"; do
if [ -d "$dir" ]; then
dest="${dir}-backup-${TIMESTAMP}"
echo -e "${RED}→${NC} Sichere ${dir} nach ${dest}..."
mv "$dir" "$dest"
BACKED_UP_DIRS+=("$dest")
echo -e "${GREEN}✔${NC} Gesichert (nicht gelöscht)."
fi
done
echo -e "${RED}→${NC} Lade Paket von ${DOWNLOAD_URL}..." echo -e "${RED}→${NC} Lade Paket von ${DOWNLOAD_URL}..."
mkdir -p "$WORK_DIR" mkdir -p "$WORK_DIR"
@@ -127,26 +84,127 @@ mkdir -p "$WORK_DIR/pkg"
tar xzf "$PACKAGE_FILE" -C "$WORK_DIR/pkg" --strip-components=1 tar xzf "$PACKAGE_FILE" -C "$WORK_DIR/pkg" --strip-components=1
echo -e "${GREEN}✔${NC} Paket entpackt." echo -e "${GREEN}✔${NC} Paket entpackt."
# Schema-Vergleich: nur wenn BEIDE Seiten eine SCHEMA_VERSION kennen UND
# sie übereinstimmen UND tatsächlich schon eine Datenbank existiert, darf
# in-place aktualisiert werden. Fehlt die Datei auf einer der beiden
# Seiten (ältere Installation/älteres Paket von vor dieser Prüfung), wird
# im Zweifel IMMER der sichere, datenerhaltende-per-Backup Weg gewählt.
CURRENT_SCHEMA_VERSION=""
[ -f /srv/tesm/SCHEMA_VERSION ] && CURRENT_SCHEMA_VERSION="$(tr -d '[:space:]' < /srv/tesm/SCHEMA_VERSION)"
NEW_SCHEMA_VERSION=""
[ -f "$WORK_DIR/pkg/srv/tesm/SCHEMA_VERSION" ] && NEW_SCHEMA_VERSION="$(tr -d '[:space:]' < "$WORK_DIR/pkg/srv/tesm/SCHEMA_VERSION")"
IN_PLACE_UPDATE=0
if [ -f /srv/tesm/sqlite.db ] && [ -n "$CURRENT_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ] \
&& [ "$CURRENT_SCHEMA_VERSION" == "$NEW_SCHEMA_VERSION" ]; then
IN_PLACE_UPDATE=1
fi
if [ "$IN_PLACE_UPDATE" -eq 1 ]; then
echo -e "${GREEN}Datenbank-Schema unverändert (Version ${CURRENT_SCHEMA_VERSION}):${NC} dies wird ein"
echo "IN-PLACE-Update. /srv/tesm bleibt bestehen -- Geräte/Switche/Zugangsdaten/"
echo "Benutzer/Einstellungen (sqlite.db) sowie Host-Keys und Verschlüsselungs-"
echo "Schlüssel (fernet.key/secret.key) bleiben unangetastet. Es werden nur"
echo "Anwendungscode, Templates und Abhängigkeiten aktualisiert."
else
if [ -n "$CURRENT_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ]; then
reason="Datenbank-Schema hat sich geändert (${CURRENT_SCHEMA_VERSION} → ${NEW_SCHEMA_VERSION})"
elif [ -f /srv/tesm/sqlite.db ]; then
reason="Schema-Version der bestehenden Installation oder des neuen Pakets unbekannt (Stand von vor dieser Prüfung)"
else
reason="keine bestehende Installation unter /srv/tesm gefunden"
fi
echo -e "${RED}ACHTUNG:${NC} Dies richtet TESM KOMPLETT NEU ein — frische Datenbank, KEINE"
echo "Geräte/Switche/Zugangsdaten/Benutzer/Einstellungen aus der aktuellen Installation."
echo "Grund: ${reason}."
echo
echo "Alle auf diesem Host ggf. vorhandenen älteren Installationen (/srv/tesm,"
echo "/srv/poe_manager) werden dabei NICHT gelöscht, sondern nach dem Stoppen der"
echo "jeweiligen Dienste nach <Verzeichnis>-backup-${TIMESTAMP} verschoben (bleiben"
echo "vollständig erhalten, inkl. sqlite.db)."
echo
echo -e "${YELLOW}Falls noch nicht geschehen: JETZT abbrechen und zuerst unter${NC}"
echo -e "${YELLOW}Einstellungen -> Im-/Export ein Backup der aktuellen Daten exportieren!${NC}"
echo "Nach dem Update entweder dieses Backup importieren, oder alles manuell neu anlegen."
fi
echo
echo "In jedem Fall gestoppt/deaktiviert werden ältere Dienste, falls noch vorhanden:"
echo " - poe_web.service (Flask-App \"PoE Manager\" unter /srv/poe_manager)"
echo " - rpi-check.service / rpi-check-restart.* (das ursprüngliche reine"
echo " Bash-Setup aus dem Repo WiS/Aruba-PoE, ganz ohne Datenbank/Web-UI)"
echo
read -r -p "Fortfahren? [y/N] " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
echo "Abgebrochen — nichts wurde verändert."
exit 0
fi
echo -e "${RED}→${NC} Stoppe und deaktiviere bestehende Dienste..."
# Alle drei historischen Generationen -- je nachdem, was auf diesem Host
# tatsächlich (noch) existiert. "|| true" macht nichts kaputt, falls
# einzelne Units gar nicht vorhanden sind. tesm.service wird auch beim
# In-Place-Update gestoppt (sauberer Neustart mit neuem Code danach über
# install.sh "Enabling services"), NUR /srv/tesm selbst bleibt erhalten.
systemctl stop tesm.service tesm-check.service tesm-check-restart.timer 2>/dev/null || true
systemctl disable tesm.service tesm-check.service tesm-check-restart.timer 2>/dev/null || true
systemctl stop poe_web.service 2>/dev/null || true
systemctl disable poe_web.service 2>/dev/null || true
systemctl stop rpi-check.service rpi-check-restart.service rpi-check-restart.timer 2>/dev/null || true
systemctl disable rpi-check.service rpi-check-restart.service rpi-check-restart.timer 2>/dev/null || true
echo -e "${GREEN}✔${NC} Dienste gestoppt und deaktiviert."
BACKED_UP_DIRS=()
# /srv/poe_manager ist eine komplett andere App/Schema -- die wird IMMER
# beiseite verschoben, nie in-place übernommen.
if [ -d /srv/poe_manager ]; then
dest="/srv/poe_manager-backup-${TIMESTAMP}"
echo -e "${RED}→${NC} Sichere /srv/poe_manager nach ${dest}..."
mv /srv/poe_manager "$dest"
BACKED_UP_DIRS+=("$dest")
echo -e "${GREEN}✔${NC} Gesichert (nicht gelöscht)."
fi
# /srv/tesm nur beiseite verschieben, wenn KEIN In-Place-Update möglich ist
# (siehe Schema-Vergleich oben) -- sonst bleibt es unangetastet stehen,
# damit install.sh gleich dagegen rsynct und die Datenbank erhalten bleibt.
if [ "$IN_PLACE_UPDATE" -eq 0 ] && [ -d /srv/tesm ]; then
dest="/srv/tesm-backup-${TIMESTAMP}"
echo -e "${RED}→${NC} Sichere /srv/tesm nach ${dest}..."
mv /srv/tesm "$dest"
BACKED_UP_DIRS+=("$dest")
echo -e "${GREEN}✔${NC} Gesichert (nicht gelöscht)."
fi
echo -e "${RED}→${NC} Starte install.sh aus dem Paket..." echo -e "${RED}→${NC} Starte install.sh aus dem Paket..."
bash "$WORK_DIR/pkg/install.sh" bash "$WORK_DIR/pkg/install.sh"
echo echo
echo -e "${GREEN}============================================================================${NC}" echo -e "${GREEN}============================================================================${NC}"
echo -e "${GREEN} Frische TESM-Installation (Release \"${RELEASE_TAG}\") abgeschlossen.${NC}" if [ "$IN_PLACE_UPDATE" -eq 1 ]; then
echo -e "${GREEN}============================================================================${NC}" echo -e "${GREEN} TESM-Update (Release \"${RELEASE_TAG}\") abgeschlossen -- Datenbank erhalten.${NC}"
echo echo -e "${GREEN}============================================================================${NC}"
echo -e "${YELLOW}WICHTIG — dies ist eine FRISCHE Installation ohne die Daten der alten Instanz.${NC}" echo
echo "Bitte jetzt eines von beidem tun:" echo "Alle Geräte/Switche/Zugangsdaten/Benutzer/Einstellungen sind unverändert"
echo " a) Alle Geräte/Switche/Zugangsdaten/Benutzer/Einstellungen manuell neu anlegen, oder" echo "vorhanden -- kein manuelles Neuanlegen oder Re-Import nötig."
echo " b) Ein vor dem Update exportiertes Backup unter Einstellungen -> Im-/Export importieren." else
echo echo -e "${GREEN} Frische TESM-Installation (Release \"${RELEASE_TAG}\") abgeschlossen.${NC}"
if [ "${#BACKED_UP_DIRS[@]}" -gt 0 ]; then echo -e "${GREEN}============================================================================${NC}"
echo
echo -e "${YELLOW}WICHTIG — dies ist eine FRISCHE Installation ohne die Daten der alten Instanz.${NC}"
echo "Bitte jetzt eines von beidem tun:"
echo " a) Alle Geräte/Switche/Zugangsdaten/Benutzer/Einstellungen manuell neu anlegen, oder"
echo " b) Ein vor dem Update exportiertes Backup unter Einstellungen -> Im-/Export importieren."
echo
if [ "${#BACKED_UP_DIRS[@]}" -gt 0 ]; then
echo "Falls kein Export gemacht wurde: die alte(n) Datenbank(en) liegen unverändert unter:" echo "Falls kein Export gemacht wurde: die alte(n) Datenbank(en) liegen unverändert unter:"
for dest in "${BACKED_UP_DIRS[@]}"; do for dest in "${BACKED_UP_DIRS[@]}"; do
echo " ${dest}/sqlite.db" echo " ${dest}/sqlite.db"
done done
echo "und können bei Bedarf manuell ausgelesen werden (z.B. mit sqlite3)." echo "und können bei Bedarf manuell ausgelesen werden (z.B. mit sqlite3)."
echo echo
fi
echo "Hinweis zum Admin-Konto der neuen Installation: siehe Ausgabe von install.sh oben"
echo "(bei einer frischen Datenbank wird dort auf create_admin.py hingewiesen)."
fi fi
echo "Hinweis zum Admin-Konto der neuen Installation: siehe Ausgabe von install.sh oben"
echo "(bei einer frischen Datenbank wird dort auf create_admin.py hingewiesen)."
+15 -10
View File
@@ -29,16 +29,21 @@ END
SLEEP=${SLEEP:-300} SLEEP=${SLEEP:-300}
# disable_poe()/enable_poe() geben jetzt den TATSÄCHLICHEN Erfolg als # disable_poe()/enable_poe(): bewusst OHNE expliziten "timeout { exit 1 }"/
# Exit-Code zurück (0 = Switch erreicht UND Kommando bestätigt, 1 = # "eof { exit 1 }"-Abbruch in den einzelnen expect-Schritten -- ein nicht
# fehlgeschlagen), statt blind anzunehmen, dass ein SSH-Verbindungsversuch # matchender Schritt laesst expect nach Ablauf von "set timeout 5" einfach
# immer klappt. Jeder kritische expect-Schritt (Verbindungsaufbau, # zur naechsten Zeile weiterlaufen, statt abzubrechen. Das ist absichtlich
# configure-terminal-Prompt, Interface-Prompt, Bestätigung des # so belassen: ein Versuch, hier "echte" Fehlererkennung nachzuruesten
# power-over-ethernet-Kommandos) hat dafür einen eigenen timeout-Zweig, # (Exit-Code 0 nur bei bestaetigtem Erfolg je Schritt), hat sich live als
# der explizit mit "exit 1" abbricht -- vorher lief das expect-Skript bei # Regression herausgestellt. Grund: das Login-Prompt-Pattern
# einem unerreichbaren Switch einfach durch alle Timeouts durch und endete # "-re ".*> $"" unten matcht nur ein Operator-Prompt ("switchname>"). Auf
# trotzdem mit Exit-Code 0, wodurch der Aufrufer fälschlich "PoE # ArubaOS-Switches, bei denen der SSH-Account (z.B. "admin") mit
# deaktiviert/aktiviert" geloggt hat, obwohl am Switch nichts passiert war. # Manager-Rechten direkt im privilegierten Modus landet ("switchname#"
# statt "switchname>", bestaetigt an einem 2930M/"ST-2000-Sued"), matcht
# das Pattern nie -- mit einem harten Timeout-Abbruch waere das faelschlich
# als Fehlschlag gewertet worden, obwohl Passwort und Verbindung in
# Ordnung waren. Diese Funktionen hier sind daher wieder exakt auf den
# Stand der urspruenglichen, in Produktion bewaehrten Fassung zurueckgesetzt.
function disable_poe() { function disable_poe() {
local switch_ip=$1 local switch_ip=$1
local switch_port=$2 local switch_port=$2