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:
+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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user