Wartung: SSH-Bulk-Update & -Neustart für Linux-Clients

Neue Seite "Wartung" (Geräte-Bereich, eigenes Rechtepaar
maintenance.view/maintenance.edit), komplett getrennt vom PoE-Neustart
auf dem Dashboard:

- Dashboard-Neustart bleibt ausschließlich PoE-Reset über den Switch
- Wartung läuft immer per SSH direkt auf dem Gerät, unabhängig von
  jeder Switch-Zuordnung -- nur Geräte mit SSH-Zugangsdaten der
  Kategorie "Linux-Client" erscheinen dort (Windows/PowerShell folgt
  als eigener, separater Schritt -- kein Testsystem dafür verfügbar)

Backend:
- _run_ssh_command()/_run_ssh_reboot(): nicht-interaktive SSH-Ausführung
  mit paramiko.RejectPolicy() gegen dieselbe known_hosts-Datei wie das
  interaktive Browser-Terminal -- ein Host muss vorher einmal per
  "Verbindung testen" bestätigt worden sein, da hier (anders als im
  Terminal) niemand eine "yes/no"-Rückfrage beantworten kann
- _apt_upgrade_command(): "apt update && apt upgrade -y" nicht-
  interaktiv mit DEBIAN_FRONTEND=noninteractive und
  --force-confdef/--force-confold, damit ein Paket-Postinst-Skript
  nicht auf eine nie kommende Config-Rückfrage wartet
- Reboot behandelt einen abrupten Verbindungsabbruch NACH dem Kommando
  als Erfolg (die Maschine fährt herunter, bevor sie antworten kann);
  nur ein Fehler VOR dem Reboot-Kommando zählt als echter Fehler
- Job-Status lebt im Prozessspeicher (wie das SSH-Terminal), Live-
  Anzeige per Polling (/maintenance/status)

Live gegen ein dediziertes Testsystem (getrennt vom gemeinsam
genutzten App-Host, um dort kein echtes apt upgrade auszulösen)
verifiziert: echtes "apt update && apt upgrade" inkl. Kernel-/systemd-/
netplan-Paketen lief vollständig durch, anschließender SSH-Neustart hat
das System nachweislich neu gestartet (uptime nach dem Job bestätigt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 11:12:07 +02:00
co-authored by Claude Sonnet 5
parent 63ca7782ba
commit 8772f7777b
5 changed files with 620 additions and 13 deletions
+94 -12
View File
@@ -25,8 +25,17 @@ Die App ermöglicht:
Host-Key zu bestätigen und die Zugangsdaten direkt zu prüfen, bevor der
Switch gespeichert wird — funktioniert auf jedem Betriebssystem, die
SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut
- Manueller PoE-Neustart einzelner Geräte per Klick; bei deaktivierten
Geräten bietet das Detail-Popup stattdessen direkt eine Aktivieren-Option
- Manueller PoE-Neustart einzelner Geräte per Klick — ausschließlich über
den zugeordneten Switch (`poe.sh`); der Button erscheint im Detail-Popup
nur, wenn dem Gerät tatsächlich Switch **und** Port zugeordnet sind (sonst
wäre kein PoE-Reset möglich), und ist immer der am weitesten links stehende
Aktions-Button — „Schließen“ steht unabhängig davon immer ganz rechts. Bei
deaktivierten Geräten bietet das Detail-Popup stattdessen direkt eine
Aktivieren-Option
- **SSH-basierte Wartung für Linux-Clients** (eigene Seite „Wartung“,
getrennt vom PoE-Neustart oben): Bulk-Update (`apt update && apt upgrade`)
und Neustart per SSH direkt auf dem Gerät, unabhängig von jeder
Switch-Zuordnung (siehe [Wartung](#wartung-ssh-bulk-update--neustart))
- Einstellbares Prüfintervall (steuert den Hintergrund-Check-Dienst) —
der Countdown „Nächste Prüfung“ wird aus dem tatsächlichen letzten
Log-Eintrag berechnet, nicht bei jedem Seitenaufruf neu gestartet, und
@@ -191,15 +200,26 @@ Bereichs-Recht nicht bei jedem Neustart automatisch wieder gesetzt wird.
## Zugangsdaten (wiederverwendbare SSH-Logins)
Switch-Zugangsdaten (Username + verschlüsseltes Passwort) sind eine eigene
Entität (`credentials`-Tabelle) statt direkt am Switch zu hängen — mehrere
Switche mit demselben Login teilen sich einen Eintrag. Beim Anlegen/Bearbeiten
eines Switches lässt sich per Dropdown ein bestehender Eintrag auswählen oder
über „+ Neue Zugangsdaten anlegen“ direkt im selben Formular ein neuer
anlegen, ohne vorher zur eigenen **Zugangsdaten**-Seite wechseln zu müssen.
Löschen ist nur möglich, wenn kein Switch mehr auf den Eintrag verweist.
Bestehende Datenbanken aus einer älteren Version (Username/Passwort direkt
am Switch) werden beim ersten Start automatisch migriert.
Zugangsdaten (Username + verschlüsseltes Passwort) sind eine eigene Entität
(`credentials`-Tabelle) statt direkt am Switch/Gerät zu hängen — mehrere
Switche/Geräte mit demselben Login teilen sich einen Eintrag. Beim Anlegen/
Bearbeiten eines Switches **oder Geräts** lässt sich per Dropdown ein
bestehender Eintrag auswählen oder über „+ Neue Zugangsdaten anlegen“ direkt
im selben Formular ein neuer anlegen, ohne vorher zur eigenen
**Zugangsdaten**-Seite wechseln zu müssen. Löschen ist nur möglich, wenn
weder ein Switch noch ein Gerät mehr auf den Eintrag verweist. Bestehende
Datenbanken aus einer älteren Version (Username/Passwort direkt am Switch)
werden beim ersten Start automatisch migriert.
Jede Zugangsdaten-Zeile hat außerdem eine explizite **Kategorie** (Switch /
Linux-Client / Windows-Client / Router-Sonstiges), vom Admin beim Anlegen
gesetzt — bewusst **nicht** automatisch per Ping/TTL-Heuristik erkannt (zu
unzuverlässig, siehe Diskussion in der Commit-Historie). Aktuell wertet nur
die Kategorie **Linux-Client** etwas aus: nur Geräte mit einer so
kategorisierten Zugangsdaten-Zuordnung erscheinen unter **Wartung** (siehe
unten). Windows/PowerShell-Wartung ist als eigener, separater Schritt
vorgesehen (kein Testsystem dafür verfügbar) — die Kategorie existiert
bereits, die Aktion dahinter noch nicht.
Jeder Switch kann außerdem einen individuellen **SSH-Port** hinterlegen
(Feld „SSH-Port“ beim Anlegen/Bearbeiten) — bleibt er leer, wird überall
@@ -210,6 +230,67 @@ sich per SSH mit einem Switch verbindet: dem Web-Terminal (Verbindungstest),
`expect`-Aufrufen in `poe.sh` selbst (`ssh -p <port> ...`) für automatische
wie manuelle PoE-Neustarts.
## Wartung (SSH-Bulk-Update & -Neustart)
Eigene Seite unter **Geräte → Wartung** (eigenes Rechtepaar
`maintenance.view`/`maintenance.edit`), komplett getrennt vom PoE-Neustart
auf dem Dashboard:
- **Dashboard-Neustart** = immer PoE-Reset über den Switch (`poe.sh`),
funktioniert unabhängig davon, ob das Gerät SSH-Zugangsdaten hat.
- **Wartung** = immer eine direkte SSH-Verbindung zum Gerät selbst,
funktioniert unabhängig davon, ob/an welchem Switch es hängt.
Nur Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie **Linux-Client**
(siehe oben) erscheinen in der Liste. Zwei Aktionen:
- **Update** (Mehrfachauswahl per Checkbox, ein Klick startet alle
ausgewählten parallel als eigene Hintergrund-Threads): führt serverseitig
`env DEBIAN_FRONTEND=noninteractive apt-get update && env
DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::="--force-confdef"
-o Dpkg::Options::="--force-confold" upgrade` per SSH aus — nicht
interaktiv (kein Terminal, keine Rückfragemöglichkeit), die
`Dpkg::Options`-Flags verhindern, dass ein Paket-Postinst-Skript auf eine
Config-Datei-Rückfrage wartet, die nie kommt und sonst bis zum Timeout
hängen bliebe.
- **Neustart** (pro Gerät): `sudo reboot` per SSH. Ein abrupter
Verbindungsabbruch direkt danach ist erwartet (die Maschine fährt
herunter, bevor sie noch antworten kann) und zählt als Erfolg, kein
Fehler — nur ein Verbindungs-/Auth-Fehler **vor** dem eigentlichen
Neustart-Kommando gilt als echter Fehler.
Live-Status pro Gerät (läuft/erfolgreich/fehlgeschlagen inkl. Ausgabe der
letzten Aktion, per Klick ein-/ausblendbar) wird per Polling (`/maintenance/status`,
alle 2s solange etwas läuft, sonst alle 8s) aktualisiert, ohne die Seite neu
zu laden. Der Job-Status lebt bewusst nur im Prozessspeicher (wie beim
SSH-Terminal auch keine Sitzung persistiert wird) — ein Neustart von
`poe_web.service` verwirft nur die Anzeige, nicht die auf dem Zielgerät
bereits laufende Aktion selbst.
**Sicherheitsmodell**: dieselbe `known_hosts`-Datei wie beim
Browser-SSH-Terminal (`SSH_KNOWN_HOSTS_PATH`) — ein Host muss vorher
mindestens einmal interaktiv über „Verbindung testen“ bestätigt worden sein.
Anders als das interaktive Terminal (dort kann bei einem unbekannten
Host-Key wie bei einem echten `ssh`-Client live nachgefragt werden) läuft
hier nichts vor einem Menschen ab — ein unbekannter oder nicht vorher
bestätigter Host-Key wird deshalb per `paramiko.RejectPolicy` hart
abgelehnt statt automatisch akzeptiert, da ohne Rückfragemöglichkeit ein
automatisches Akzeptieren ein MITM-Risiko ohne jede Prüfung wäre.
Live gegen ein dediziertes Testsystem verifiziert (separat vom gemeinsam
genutzten App-Host, um dort kein echtes `apt upgrade` auszulösen): ein
echtes `apt update && apt upgrade` (inkl. Kernel-/systemd-/netplan-Paketen)
lief nicht-interaktiv vollständig durch (`status: success`, komplette
`apt`-Ausgabe im Job-Verlauf sichtbar), anschließend hat der SSH-Neustart
das Testsystem tatsächlich neu gestartet — bestätigt über `uptime -s`
direkt nach dem Job (Boot-Zeitpunkt stimmte mit dem Auslöse-Zeitpunkt
überein, nicht nur die vom Code erwartete Verbindungsunterbrechung).
Windows-Clients/PowerShell-Wartung ist bewusst **noch nicht** umgesetzt
(kein Testsystem verfügbar) — die Kategorie „Windows-Client“ existiert in
den Zugangsdaten bereits als Vorbereitung für einen späteren, separaten
Ausbauschritt.
## DHCP-Reservierungen & eigene Options (Systemeinstellungen → DHCP)
Erzeugt aus den vorhandenen Client-Stammdaten (MAC + IP + Name) automatisch
@@ -516,7 +597,8 @@ verursachen, obwohl der Code auf dem Server längst aktuell war.
Die Sidebar ist in drei aufklappbare Gruppen strukturiert:
- **Geräte** — Clients (die eigentlichen Devices), Switche, Zugangsdaten
- **Geräte** — Clients (die eigentlichen Devices), Switche, Zugangsdaten,
Wartung (SSH-Bulk-Update/-Neustart)
- **Einstellungen** — Benutzer, Gruppen, Systemeinstellungen (Prüfintervall),
Im-/Export
- **Logs** — Live, Änderungen (Änderungslog)
+366 -1
View File
@@ -18,7 +18,7 @@ from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from datetime import datetime
import base64, csv, ipaddress, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
import base64, csv, ipaddress, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
import paramiko
import yaml
@@ -196,6 +196,17 @@ PERMISSIONS = {
"edit": "credentials.edit",
},
},
"maintenance": {
"label": "Wartung",
# Kein eigenes Löschen — "Ändern" (E) steht hier für das
# Ausführen von Wartungsaktionen (SSH-Update, SSH-Neustart),
# nicht für Löschen. Getrennt von devices.restart (D oben):
# das ist ausschließlich der PoE-Neustart über den Switch.
"rows": {
"view": "maintenance.view",
"edit": "maintenance.edit",
},
},
},
},
"logs_group": {
@@ -258,6 +269,8 @@ PERMISSION_LABELS = {
"switches.edit": "Switche ändern (inkl. Löschen)",
"credentials.view": "Zugangsdaten lesen", "credentials.create": "Zugangsdaten anlegen",
"credentials.edit": "Zugangsdaten ändern (inkl. Löschen)",
"maintenance.view": "Wartungsseite ansehen (Geräte-Status, Job-Verlauf)",
"maintenance.edit": "Wartungsaktionen ausführen (SSH-Update, SSH-Neustart)",
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen",
"users.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
"users.edit": "Benutzer ändern (inkl. Löschen)",
@@ -317,6 +330,7 @@ NAV_ITEMS = [
{"key": "devices", "label": "Clients", "icon": "cpu", "endpoint": "devices"},
{"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"},
{"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"},
{"key": "maintenance", "label": "Wartung", "icon": "wrench", "endpoint": "maintenance"},
]},
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
@@ -343,6 +357,8 @@ def _nav_key_visible(key, user):
return user.can_manage_switches
if key == "credentials":
return user.can_manage_credentials
if key == "maintenance":
return user.can_view_maintenance
if key == "users":
return user.can_manage_users
if key == "groups":
@@ -468,6 +484,14 @@ class User(UserMixin):
["credentials.view", "credentials.create", "credentials.edit"]
)
@property
def can_view_maintenance(self):
return self.has_any_permission(["maintenance.view", "maintenance.edit"])
@property
def can_run_maintenance(self):
return self.has_permission("maintenance.edit")
@property
def can_view_live_log(self):
return self.has_permission("logs_live.view")
@@ -3321,6 +3345,183 @@ def credentials():
return render_template("credentials.html", credentials=credential_rows, categories=CREDENTIAL_CATEGORIES)
# ---------------------------------------------------------------------------
# Wartung (Clients) — SSH-Bulk-Update + Neustart für Linux-Clients.
#
# Bewusst komplett getrennt vom PoE-Neustart auf dem Dashboard: der
# Dashboard-Button (devices.restart) löst IMMER einen PoE-Reset über den
# zugeordneten Switch aus. Die Aktionen hier laufen IMMER per SSH direkt
# auf dem Gerät selbst -- unabhängig davon, ob/wie es an einen Switch
# angeschlossen ist. Job-Status lebt bewusst nur im Prozessspeicher (wie
# das SSH-Terminal auch keine Sitzung persistiert) -- ein Neustart des
# poe_web.service verwirft laufende Job-Anzeigen, nicht aber die
# zugrundeliegende SSH-Aktion selbst (die läuft serverseitig auf dem
# Zielgerät unabhängig vom Anzeigezustand hier weiter).
# ---------------------------------------------------------------------------
_maintenance_jobs_lock = threading.Lock()
# mac -> {"action": "update"/"reboot", "status": "running"/"success"/"error",
# "message": str, "output": str, "started": str, "finished": str|None}
_maintenance_jobs = {}
def _maintenance_job_set(mac, **fields):
with _maintenance_jobs_lock:
job = _maintenance_jobs.setdefault(mac, {})
job.update(fields)
def _maintenance_job_immediate_error(mac, action, message):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
_maintenance_job_set(mac, action=action, status="error", message=message, output="", started=now, finished=now)
def _maintenance_devices(conn):
"""Alle Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie
DEVICE_MAINTENANCE_CATEGORY (aktuell nur 'linux') -- nur für diese
bietet 'Wartung' überhaupt eine Aktion an (Windows/PowerShell folgt
als eigener, separater Schritt, siehe CREDENTIAL_CATEGORIES-Kommentar)."""
return conn.execute(
"""
SELECT devices.mac, devices.name, devices.rpi_ip, devices.ssh_port,
devices.credential_id, devices.is_active,
credentials.username AS cred_username
FROM devices
JOIN credentials ON credentials.id = devices.credential_id
WHERE credentials.category = ?
ORDER BY devices.name ASC
""",
(DEVICE_MAINTENANCE_CATEGORY,),
).fetchall()
@app.route("/maintenance")
@login_required
def maintenance():
if not current_user.can_view_maintenance:
flash("Keine Berechtigung für die Wartungsseite.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
devices_rows = _maintenance_devices(conn)
conn.close()
with _maintenance_jobs_lock:
jobs_snapshot = {mac: dict(job) for mac, job in _maintenance_jobs.items()}
return render_template(
"maintenance.html",
devices=devices_rows,
jobs=jobs_snapshot,
can_run=current_user.can_run_maintenance,
)
@app.route("/maintenance/status")
@login_required
def maintenance_status():
if not current_user.can_view_maintenance:
return jsonify({"error": "Keine Berechtigung."}), 403
with _maintenance_jobs_lock:
return jsonify({mac: dict(job) for mac, job in _maintenance_jobs.items()})
def _maintenance_run_update(mac, name, host, port, username, password):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
_maintenance_job_set(
mac, action="update", status="running", message="Update läuft …",
output="", started=now, finished=None,
)
# timeout=600: ein echtes "apt upgrade" kann bei vielen ausstehenden
# Paketen mehrere Minuten dauern -- der kurze Standard-Timeout der
# interaktiven SSH-Aktionen (25s) wäre hier viel zu knapp.
result = _run_ssh_command(
host, port, username, password, _apt_upgrade_command(),
sudo_password=password, timeout=600,
)
finished = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
combined_output = (result.get("stdout", "") + result.get("stderr", ""))[-6000:]
if result["error"]:
_maintenance_job_set(mac, status="error", message=result["error"], output=combined_output, finished=finished)
log_action_system("maintenance.update", name, f"fehlgeschlagen: {result['error']}")
elif result["success"]:
_maintenance_job_set(mac, status="success", message="Update erfolgreich abgeschlossen.", output=combined_output, finished=finished)
log_action_system("maintenance.update", name, "erfolgreich")
else:
_maintenance_job_set(
mac, status="error",
message=f"Update fehlgeschlagen (Exit-Code {result['exit_code']}).",
output=combined_output, finished=finished,
)
log_action_system("maintenance.update", name, f"fehlgeschlagen: Exit-Code {result['exit_code']}")
def _maintenance_run_reboot(mac, name, host, port, username, password):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
_maintenance_job_set(mac, action="reboot", status="running", message="Neustart wird ausgelöst …", output="", started=now, finished=None)
result = _run_ssh_reboot(host, port, username, password)
finished = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if result["error"]:
_maintenance_job_set(mac, status="error", message=result["error"], finished=finished)
log_action_system("maintenance.reboot", name, f"fehlgeschlagen: {result['error']}")
else:
_maintenance_job_set(mac, status="success", message="Neustart ausgelöst.", finished=finished)
log_action_system("maintenance.reboot", name, "ausgelöst")
def _maintenance_dispatch(action_name, thread_target):
"""Gemeinsame Logik für /maintenance/update und /maintenance/reboot:
ausgewählte, berechtigte MACs auflösen, bereits laufende überspringen,
je Gerät einen Hintergrund-Thread starten."""
macs = set(request.form.getlist("macs"))
if not macs:
flash("Bitte mindestens ein Gerät auswählen.", "danger")
return redirect(url_for("maintenance"))
conn = get_db_connection()
eligible = {d["mac"]: d for d in _maintenance_devices(conn) if d["mac"] in macs}
started = []
for mac, device in eligible.items():
with _maintenance_jobs_lock:
existing = _maintenance_jobs.get(mac)
if existing and existing.get("status") == "running":
continue
host, port, username, password, error = _resolve_device_ssh_target(conn, device)
if error:
_maintenance_job_immediate_error(mac, action_name, error)
continue
thread = threading.Thread(
target=thread_target,
args=(mac, device["name"], host, port, username, password),
daemon=True,
)
thread.start()
started.append(device["name"])
conn.close()
if started:
flash(f"{action_name.capitalize()} gestartet für: {', '.join(started)}", "success")
else:
flash("Keine Aktion gestartet (bereits laufend oder Zugangsdaten fehlerhaft).", "danger")
return redirect(url_for("maintenance"))
@app.route("/maintenance/update", methods=["POST"])
@login_required
def maintenance_update():
if not current_user.can_run_maintenance:
flash("Keine Berechtigung, Wartungsaktionen auszuführen.", "danger")
return redirect(url_for("maintenance"))
return _maintenance_dispatch("update", _maintenance_run_update)
@app.route("/maintenance/reboot", methods=["POST"])
@login_required
def maintenance_reboot():
if not current_user.can_run_maintenance:
flash("Keine Berechtigung, Wartungsaktionen auszuführen.", "danger")
return redirect(url_for("maintenance"))
return _maintenance_dispatch("reboot", _maintenance_run_reboot)
# ---------------------------------------------------------------------------
# Browser-SSH-Terminal — Verbindungstest beim Anlegen/Bearbeiten von Switchen.
#
@@ -3623,6 +3824,170 @@ def _ssh_terminal_inner(ws):
pass
# ---------------------------------------------------------------------------
# Wartung: nicht-interaktive SSH-Kommandos (Bulk-Update, Neustart)
#
# Bewusst getrennt von _ssh_terminal_inner() oben: dort kann bei einem
# unbekannten Host-Key interaktiv "yes/no" nachgefragt werden (wie ein
# echtes ssh-CLI). Hier läuft nichts vor einem Menschen ab (Bulk-Aktion über
# mehrere Geräte per Thread) -- ein unbekannter/nicht vorher bestätigter
# Host-Key wird deshalb hart abgelehnt (RejectPolicy) statt automatisch
# akzeptiert (AutoAddPolicy wäre hier ein MITM-Risiko ohne jede Prüfung).
# Ein Host muss also immer zuerst einmal über "Verbindung testen" (das
# interaktive Terminal) bestätigt worden sein, bevor Wartungsaktionen für
# ihn möglich sind -- beide Wege teilen sich dieselbe SSH_KNOWN_HOSTS_PATH.
# ---------------------------------------------------------------------------
def _apt_upgrade_command():
"""Sicheres, nicht-interaktives 'apt update && apt upgrade -y':
DEBIAN_FRONTEND=noninteractive plus --force-confdef/--force-confold
verhindern, dass ein Paket-Postinst-Skript auf eine Konfig-Datei-
Rückfrage wartet, die nie kommt (das Kommando würde sonst ohne PTY
unbeaufsichtigt hängen bleiben, bis der Timeout zuschlägt)."""
inner = (
"env DEBIAN_FRONTEND=noninteractive apt-get update && "
'env DEBIAN_FRONTEND=noninteractive apt-get -y '
'-o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade'
)
return f"sudo -S -p '' bash -c {shlex.quote(inner)}"
def _resolve_device_ssh_target(conn, device):
"""Löst Host/Port/Zugangsdaten für eine SSH-Wartungsaktion an einem
Gerät auf. Gibt (host, port, username, password, error) zurück -- bei
einem Fehler ist error gesetzt und die übrigen Werte None."""
if not device["credential_id"]:
return None, None, None, None, f"{device['name']}: keine SSH-Zugangsdaten hinterlegt."
cred = conn.execute(
"SELECT username, password FROM credentials WHERE id=?", (device["credential_id"],)
).fetchone()
if not cred:
return None, None, None, None, f"{device['name']}: hinterlegte Zugangsdaten existieren nicht mehr."
port = device["ssh_port"] or SWITCH_DEFAULT_SSH_PORT
try:
password = decrypt_password(cred["password"])
except Exception:
return None, None, None, None, f"{device['name']}: Zugangsdaten konnten nicht entschlüsselt werden."
return device["rpi_ip"], port, cred["username"], password, None
def _run_ssh_command(host, port, username, password, command, sudo_password=None, timeout=25):
"""Führt EIN nicht-interaktives Kommando per SSH aus (kein Terminal,
keine Rückfragemöglichkeit). Gibt ein dict zurück:
{"success": bool, "exit_code": int|None, "stdout": str, "stderr": str,
"error": str|None} -- "error" ist nur bei Verbindungs-/Auth-Fehlern
gesetzt, ein regulär durchgelaufenes aber fehlgeschlagenes Kommando
(exit_code != 0) zählt als success=False mit leerem error."""
result = {"success": False, "exit_code": None, "stdout": "", "stderr": "", "error": None}
if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
result["error"] = (
"Host-Key nicht bekannt — bitte zuerst per 'Verbindung testen' "
"einmal interaktiv verbinden und den Host-Key bestätigen."
)
return result
client = paramiko.SSHClient()
client.load_host_keys(SSH_KNOWN_HOSTS_PATH)
client.set_missing_host_key_policy(paramiko.RejectPolicy())
try:
client.connect(
host, port=port, username=username, password=password,
timeout=timeout, banner_timeout=timeout, auth_timeout=timeout,
look_for_keys=False, allow_agent=False,
)
except paramiko.BadHostKeyException:
result["error"] = "Host-Key hat sich geändert — Verbindung abgelehnt (möglicher Man-in-the-Middle)."
return result
except paramiko.AuthenticationException:
result["error"] = "Authentifizierung fehlgeschlagen (falsche Zugangsdaten)."
return result
except paramiko.SSHException as e:
result["error"] = f"Host-Key unbekannt — bitte zuerst per 'Verbindung testen' bestätigen ({e})."
return result
except (OSError, socket.error) as e:
result["error"] = f"Verbindung fehlgeschlagen: {e}"
return result
try:
stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
if sudo_password is not None:
try:
stdin.write(sudo_password + "\n")
stdin.flush()
except OSError:
pass
result["exit_code"] = stdout.channel.recv_exit_status()
result["stdout"] = stdout.read().decode("utf-8", errors="replace")
result["stderr"] = stderr.read().decode("utf-8", errors="replace")
result["success"] = result["exit_code"] == 0
except (paramiko.SSHException, OSError) as e:
result["error"] = f"Kommando fehlgeschlagen: {e}"
finally:
try:
client.close()
except Exception:
pass
return result
def _run_ssh_reboot(host, port, username, password, timeout=15):
"""Startet 'sudo reboot' per SSH. Verbindungs-/Auth-Fehler VOR dem
eigentlichen Neustart-Kommando gelten als echter Fehler; ein abrupter
Verbindungsabbruch WÄHREND/NACH dem Kommando ist dagegen erwartet (die
Maschine fährt herunter, bevor sie überhaupt noch antworten kann) und
zählt als Erfolg."""
result = {"success": False, "error": None}
if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
result["error"] = (
"Host-Key nicht bekannt — bitte zuerst per 'Verbindung testen' "
"einmal interaktiv verbinden und den Host-Key bestätigen."
)
return result
client = paramiko.SSHClient()
client.load_host_keys(SSH_KNOWN_HOSTS_PATH)
client.set_missing_host_key_policy(paramiko.RejectPolicy())
try:
client.connect(
host, port=port, username=username, password=password,
timeout=timeout, banner_timeout=timeout, auth_timeout=timeout,
look_for_keys=False, allow_agent=False,
)
except paramiko.BadHostKeyException:
result["error"] = "Host-Key hat sich geändert — Verbindung abgelehnt (möglicher Man-in-the-Middle)."
return result
except paramiko.AuthenticationException:
result["error"] = "Authentifizierung fehlgeschlagen (falsche Zugangsdaten)."
return result
except paramiko.SSHException as e:
result["error"] = f"Host-Key unbekannt — bitte zuerst per 'Verbindung testen' bestätigen ({e})."
return result
except (OSError, socket.error) as e:
result["error"] = f"Verbindung fehlgeschlagen: {e}"
return result
try:
stdin, stdout, stderr = client.exec_command("sudo -S -p '' reboot", timeout=timeout)
stdin.write(password + "\n")
stdin.flush()
stdout.channel.recv_exit_status()
except (paramiko.SSHException, OSError, EOFError):
pass # Verbindungsabbruch durch den Neustart selbst ist erwartet
finally:
try:
client.close()
except Exception:
pass
result["success"] = True
return result
# ---------------------------------------------------------------------------
# Logs
# ---------------------------------------------------------------------------
+15
View File
@@ -750,6 +750,21 @@ table.data-table {
.row-actions { display: flex; align-items: center; gap: 6px; flex-wrap: nowrap; }
.row-actions form { display: flex; }
.job-output-pre {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-dim);
background: var(--bg-input);
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 12px 14px;
margin: 6px 0;
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.empty-row td {
text-align: center;
padding: 40px 16px;
+1
View File
@@ -24,6 +24,7 @@
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
"transfer": '<path d="M17 3l4 4-4 4"/><path d="M3 7h18"/><path d="M7 21l-4-4 4-4"/><path d="M21 17H3"/>',
"network": '<rect x="9" y="2" width="6" height="6" rx="1.2"/><rect x="2" y="16" width="6" height="6" rx="1.2"/><rect x="16" y="16" width="6" height="6" rx="1.2"/><path d="M12 8v4M12 12H5v4M12 12h7v4"/>',
"wrench": '<path d="M14.7 6.3a4 4 0 11-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 015.4-5.4z"/>',
} %}
<div class="app-shell">
+144
View File
@@ -0,0 +1,144 @@
{% extends "base.html" %}
{% set active_page = "maintenance" %}
{% block page_title %}Wartung{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Wartung (SSH)</h2>
<div class="hint">
Bulk-Update (<code>apt update &amp; upgrade -y</code>) und Neustart per SSH direkt auf dem Gerät —
unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten
SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.
</div>
</div>
</div>
{% if not devices %}
<p class="text-faint" style="font-size:12.5px;">
Noch keine Geräte für die Wartung konfiguriert. Lege unter
<a href="{{ url_for('devices') }}" style="color:var(--accent-strong); font-weight:600;">Clients</a>
SSH-Zugangsdaten der Kategorie „Linux-Client“ an einem Gerät an — es erscheint danach automatisch hier.
</p>
{% else %}
<form method="post" action="{{ url_for('maintenance_update') }}" id="updateForm">
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table" data-sortable>
<thead><tr>
{% if can_run %}<th style="width:1%;"><input type="checkbox" id="selectAll" title="Alle auswählen"></th>{% endif %}
<th data-sort-key="name">Name</th>
<th data-sort-key="ip">IP-Adresse</th>
<th data-sort-key="user">SSH-User</th>
<th data-sort-key="status">Status</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<tbody>
{% for d in devices %}
{% set job = jobs.get(d['mac']) %}
<tr data-mac="{{ d['mac'] }}" data-sort-name="{{ d['name']|lower }}" data-sort-ip="{{ d['rpi_ip'] }}" data-sort-user="{{ d['cred_username']|lower }}">
{% if can_run %}
<td><input type="checkbox" name="macs" value="{{ d['mac'] }}" class="maint-check" {{ 'disabled' if not d['is_active'] else '' }}></td>
{% endif %}
<td class="cell-name">{{ d['name'] }}{% if not d['is_active'] %} <span class="text-faint" style="font-size:11px;">(deaktiviert)</span>{% endif %}</td>
<td class="mono">{{ d['rpi_ip'] }}:{{ d['ssh_port'] or 22 }}</td>
<td class="mono">{{ d['cred_username'] }}</td>
<td class="maint-status">
{% if job %}
{% if job.status == 'running' %}<span class="pill unknown"><span class="spinner"></span> {{ job.message }}</span>
{% elif job.status == 'success' %}<span class="pill online">{{ job.message }}</span>
{% else %}<span class="pill offline">{{ job.message }}</span>
{% endif %}
{% else %}
<span class="pill disabled">Noch keine Aktion</span>
{% endif %}
</td>
<td>
<div class="row-actions">
<button type="button" class="icon-btn" title="Ausgabe anzeigen/verbergen" onclick="toggleJobOutput('{{ d['mac'] }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
{% if can_run %}
<form method="post" action="{{ url_for('maintenance_reboot') }}" data-confirm="{{ d['name'] }} ({{ d['rpi_ip'] }}) jetzt per SSH neu starten?">
<input type="hidden" name="macs" value="{{ d['mac'] }}">
<button type="submit" class="btn btn-sm btn-secondary">Neustart</button>
</form>
{% endif %}
</div>
</td>
</tr>
<tr class="job-output-row" id="joboutput-{{ d['mac'] }}" style="display:none;">
<td colspan="{{ 6 if can_run else 5 }}">
<pre class="job-output-pre">{{ job.output if job and job.output else 'Keine Ausgabe.' }}</pre>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if can_run %}
<div class="section-head" style="margin-top:16px;">
<div class="hint">Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet).</div>
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
Update starten
</button>
</div>
{% endif %}
</form>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
document.getElementById("selectAll")?.addEventListener("change", function () {
document.querySelectorAll(".maint-check").forEach(function (cb) {
if (!cb.disabled) cb.checked = this.checked;
}, this);
});
function toggleJobOutput(mac) {
const row = document.getElementById("joboutput-" + mac);
if (row) row.style.display = (row.style.display === "none" || !row.style.display) ? "table-row" : "none";
}
function statusPillHtml(job) {
if (!job) return '<span class="pill disabled">Noch keine Aktion</span>';
const msg = job.message || "";
if (job.status === "running") return '<span class="pill unknown"><span class="spinner"></span> ' + msg + '</span>';
if (job.status === "success") return '<span class="pill online">' + msg + '</span>';
return '<span class="pill offline">' + msg + '</span>';
}
let maintPollTimer = null;
function pollMaintenanceStatus() {
fetch("{{ url_for('maintenance_status') }}")
.then(function (r) { return r.json(); })
.then(function (jobs) {
let anyRunning = false;
document.querySelectorAll("tr[data-mac]").forEach(function (row) {
const mac = row.dataset.mac;
const job = jobs[mac];
const cell = row.querySelector(".maint-status");
if (cell) cell.innerHTML = statusPillHtml(job);
const outRow = document.getElementById("joboutput-" + mac);
if (outRow && job) {
const pre = outRow.querySelector(".job-output-pre");
if (pre) pre.textContent = job.output || "Keine Ausgabe.";
}
if (job && job.status === "running") anyRunning = true;
});
clearTimeout(maintPollTimer);
maintPollTimer = setTimeout(pollMaintenanceStatus, anyRunning ? 2000 : 8000);
})
.catch(function () { maintPollTimer = setTimeout(pollMaintenanceStatus, 8000); });
}
pollMaintenanceStatus();
</script>
{% endblock %}