From 70f6b005a9b4f9eb4a6148a8ec885f41d190cd14 Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Sat, 12 Sep 2026 21:10:39 +0200 Subject: [PATCH] Version 2.8.4: Konsole meldet jede Stufe, Befehl switch-test --- apps/tesm/pyproject.toml | 2 +- apps/tesm/src/tesm/blueprints/terminal.py | 25 ++++- apps/tesm/src/tesm/cli.py | 124 ++++++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index 81a53f0..2709ac9 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.8.3" +version = "2.8.4" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/terminal.py b/apps/tesm/src/tesm/blueprints/terminal.py index 26bceb3..b51c76d 100644 --- a/apps/tesm/src/tesm/blueprints/terminal.py +++ b/apps/tesm/src/tesm/blueprints/terminal.py @@ -211,7 +211,17 @@ def init_app(app) -> None: # type: ignore[no-untyped-def] def _send(ws, kind: str, text: str) -> None: # type: ignore[no-untyped-def] - ws.send(json.dumps({"type": kind, "data": text})) + """Schickt eine Nachricht. Ein geschlossener Socket ist kein Fehler. + + Bricht der Browser die Sitzung ab, schlaegt das Senden fehl -- und die + Ausnahme daraus verdeckte bisher den eigentlichen Grund, warum die + Sitzung endete: im Protokoll stand ein Stapelverlauf aus ``ws.send``, + nicht die Meldung des Switches. + """ + try: + ws.send(json.dumps({"type": kind, "data": text})) + except Exception: # noqa: BLE001 - der Browser ist weg, das ist keine Stoerung + logger.debug("Konsolennachricht nicht mehr zustellbar (%s)", kind) def _transcript_name(label: str) -> str: @@ -220,8 +230,17 @@ def _transcript_name(label: str) -> str: def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-untyped-def] + """Eine Konsolensitzung, von der Berechtigung bis zum Abmelden. + + Jede Stufe schickt eine Zeile in die Oberflaeche. Das ist keine Zierde: + eine Konsole, die nichts sagt, laesst offen, ob die Berechtigung fehlt, + die Zugangsdaten, die Verbindung oder die Antwort des Geraets -- und genau + diese Frage stand im Betrieb im Raum. Sichtbar sind die Zeilen erst + nuetzlich, wenn sie **vor** dem Warten kommen, nicht danach. + """ extension = core() conn = db() + _send(ws, "info", "Sitzung wird geöffnet …") # -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein # WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich. @@ -255,6 +274,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un / f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log" ) + _send(ws, "info", f"Ziel: {target.label} ({target.host}:{target.port})") try: username, password = inventory.credential_secret( conn, extension.keystore, target.credential_id @@ -262,6 +282,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un except inventory.ValidationError as exc: _send(ws, "error", str(exc)) return + _send(ws, "info", f"Anmeldung als {username} …") with extension.database.transaction(conn): audit.log( @@ -343,6 +364,7 @@ def _run_switch_session( # type: ignore[no-untyped-def] ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int ) -> int: commands = 0 + _send(ws, "info", "Warte auf die Eingabeaufforderung des Switches …") with switchcli.SwitchCli( conn, host=target.host, port=target.port, username=username, password=password ) as cli: @@ -375,6 +397,7 @@ def _run_client_session( # type: ignore[no-untyped-def] ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int ) -> int: commands = 0 + _send(ws, "info", "Verbindung wird aufgebaut …") with shellcli.ShellSession( conn, host=target.host, port=target.port, username=username, password=password ) as session: diff --git a/apps/tesm/src/tesm/cli.py b/apps/tesm/src/tesm/cli.py index c5c8286..5179174 100644 --- a/apps/tesm/src/tesm/cli.py +++ b/apps/tesm/src/tesm/cli.py @@ -216,6 +216,114 @@ def cmd_web_setup(args: argparse.Namespace) -> int: return run_web_setup(app, extension, args) +def cmd_switch_test(args: argparse.Namespace) -> int: + """Prueft Stufe fuer Stufe, wo eine Switch-Sitzung haengt. + + Warum ein eigener Befehl und nicht die Konsole im Browser: dort liegen + zwischen dem Switch und dem Auge ein WebSocket, ein Reverse Proxy und + JavaScript. Wenn nichts ankommt, ist die erste Frage, *welche* dieser + Schichten schweigt. Hier gibt es keine davon -- und jede Stufe sagt, was + sie bekommen hat, notfalls als rohe Bytes. + + Liest nur, ausser mit ``--zyklus``: dann wird der Port wirklich + geschaltet. + """ + from .services import inventory, switchcli + from .services.ssh import SshError + from tesm_core.db import Database + from tesm_core.extension import core + + app = create_app() + erweiterung = core(app) + + def sagen(stufe: str, text: str = "") -> None: + print(f" {stufe:<28} {text}".rstrip()) + + with app.app_context(): + with erweiterung.database.session() as conn: + zeile = Database.one( + conn, + "SELECT id, hostname, ip, ssh_port, credential_id FROM switches " + "WHERE (hostname=? COLLATE NOCASE OR ip=?) AND deleted_at IS NULL", + (args.switch, args.switch), + ) + if zeile is None: + print(f"Kein Switch mit Name oder Adresse {args.switch!r}.") + print("Vorhanden:") + for andere in Database.all( + conn, "SELECT hostname, ip FROM switches WHERE deleted_at IS NULL" + ): + print(f" {andere['hostname']} ({andere['ip']})") + return 2 + + host = str(zeile["ip"]) + ssh_port = int(zeile["ssh_port"] or 22) + print(f"Switch {zeile['hostname']} ({host}:{ssh_port})") + print() + + if not zeile["credential_id"]: + sagen("Zugangsdaten", "FEHLT -- dem Switch sind keine zugeordnet") + return 1 + try: + benutzer, passwort = inventory.credential_secret( + conn, erweiterung.keystore, int(zeile["credential_id"]) + ) + except Exception as fehler: # noqa: BLE001 + sagen("Zugangsdaten", f"nicht lesbar: {fehler}") + return 1 + sagen("Zugangsdaten", f"{benutzer} (Passwort {len(passwort)} Zeichen)") + + from .services import ssh as ssh_modul + + for record in ssh_modul.all_known_keys(conn, host, ssh_port): + sagen("Freigegebener Schluessel", f"{record.key_type} {record.fingerprint}") + + try: + sitzung = switchcli.SwitchCli( + conn, + host=host, + port=ssh_port, + username=benutzer, + password=passwort, + ) + with sitzung as cli: + sagen("Anmeldung", "erfolgreich") + erstes = cli.full_transcript + sagen("Erste Antwort", f"{len(erstes)} Zeichen") + print() + print("--- was der Switch nach der Anmeldung geschickt hat ---") + print(erstes if erstes.strip() else "(nichts)") + print("--- Ende ---") + print() + + if args.port: + ausgabe = cli.send( + f"show power-over-ethernet {args.port}", tolerate_error=True + ) + sagen("Zustandsabfrage", f"{len(ausgabe)} Zeichen") + print("--- show power-over-ethernet ---") + print(ausgabe) + print("--- Ende ---") + sagen("Erkannter Zustand", cli.poe_state(args.port)) + + if args.zyklus: + if not args.port: + print("Fuer --zyklus braucht es --port.") + return 2 + ergebnis = cli.cycle_poe(args.port) + sagen("Portzyklus", "erfolgreich" if ergebnis.ok else ergebnis.error) + for schritt in ergebnis.steps: + sagen("", schritt) + if not ergebnis.ok: + return 1 + except SshError as fehler: + sagen("FEHLGESCHLAGEN", str(fehler)) + return 1 + print() + print("Fertig.") + return 0 + + def cmd_convert_legacy(args: argparse.Namespace) -> int: """Uebersetzt einen Export des alten TESM in das neue Format. @@ -418,6 +526,22 @@ def build_parser() -> argparse.ArgumentParser: check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen") check.set_defaults(func=cmd_check_now) + pruefung = sub.add_parser( + "switch-test", + help="Eine Switch-Sitzung Stufe fuer Stufe pruefen", + description=( + "Meldet sich am Switch an und zeigt, was er schickt -- ohne Browser, " + "ohne WebSocket, ohne Reverse Proxy. Liest nur; mit --zyklus wird der " + "Port wirklich geschaltet." + ), + ) + pruefung.add_argument("switch", help="Name oder IP des Switches, wie in TESM hinterlegt") + pruefung.add_argument("--port", help="Portbezeichnung, z. B. 1/8 -- fragt den Zustand ab") + pruefung.add_argument( + "--zyklus", action="store_true", help="PoE wirklich aus- und wieder einschalten" + ) + pruefung.set_defaults(func=cmd_switch_test) + legacy = sub.add_parser( "convert-legacy", help="Export des alten TESM in das neue Format übersetzen",