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:
@@ -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
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 & 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 %}
|
||||
Reference in New Issue
Block a user