From 8772f7777b0c167e016b47f99c41e76aeecd17fd Mon Sep 17 00:00:00 2001 From: alientim Date: Wed, 12 Aug 2026 11:12:07 +0200 Subject: [PATCH] =?UTF-8?q?Wartung:=20SSH-Bulk-Update=20&=20-Neustart=20f?= =?UTF-8?q?=C3=BCr=20Linux-Clients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 106 +++++- srv/poe_manager/app.py | 367 ++++++++++++++++++++- srv/poe_manager/static/css/style.css | 15 + srv/poe_manager/templates/base.html | 1 + srv/poe_manager/templates/maintenance.html | 144 ++++++++ 5 files changed, 620 insertions(+), 13 deletions(-) create mode 100644 srv/poe_manager/templates/maintenance.html diff --git a/README.md b/README.md index 486e1f9..92be067 100644 --- a/README.md +++ b/README.md @@ -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 ...`) 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) diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index dff47dc..6d2829e 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -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 # --------------------------------------------------------------------------- diff --git a/srv/poe_manager/static/css/style.css b/srv/poe_manager/static/css/style.css index b882710..465c149 100644 --- a/srv/poe_manager/static/css/style.css +++ b/srv/poe_manager/static/css/style.css @@ -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; diff --git a/srv/poe_manager/templates/base.html b/srv/poe_manager/templates/base.html index 69242e4..d5aec55 100644 --- a/srv/poe_manager/templates/base.html +++ b/srv/poe_manager/templates/base.html @@ -24,6 +24,7 @@ "gear": '', "transfer": '', "network": '', + "wrench": '', } %}
diff --git a/srv/poe_manager/templates/maintenance.html b/srv/poe_manager/templates/maintenance.html new file mode 100644 index 0000000..f5555c0 --- /dev/null +++ b/srv/poe_manager/templates/maintenance.html @@ -0,0 +1,144 @@ +{% extends "base.html" %} +{% set active_page = "maintenance" %} +{% block page_title %}Wartung{% endblock %} +{% block page_sub %}
{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten
{% endblock %} + +{% block content %} + +
+
+

Wartung (SSH)

+
+ Bulk-Update (apt update & upgrade -y) 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. +
+
+
+ +{% if not devices %} +

+ Noch keine Geräte für die Wartung konfiguriert. Lege unter + Clients + SSH-Zugangsdaten der Kategorie „Linux-Client“ an einem Gerät an — es erscheint danach automatisch hier. +

+{% else %} + +
+
+
+ + + {% if can_run %}{% endif %} + + + + + + + + {% for d in devices %} + {% set job = jobs.get(d['mac']) %} + + {% if can_run %} + + {% endif %} + + + + + + + + + + {% endfor %} + +
NameIP-AdresseSSH-UserStatusAktionen
{{ d['name'] }}{% if not d['is_active'] %} (deaktiviert){% endif %}{{ d['rpi_ip'] }}:{{ d['ssh_port'] or 22 }}{{ d['cred_username'] }} + {% if job %} + {% if job.status == 'running' %} {{ job.message }} + {% elif job.status == 'success' %}{{ job.message }} + {% else %}{{ job.message }} + {% endif %} + {% else %} + Noch keine Aktion + {% endif %} + +
+ + {% if can_run %} + + + + + {% endif %} +
+
+
+
+ +{% if can_run %} +
+
Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet).
+ +
+{% endif %} + +{% endif %} + +{% endblock %} + +{% block scripts %} + +{% endblock %}