diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index b9005dc..81a53f0 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.2" +version = "2.8.3" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/services/switchcli.py b/apps/tesm/src/tesm/services/switchcli.py index 09d4edf..e8883b4 100644 --- a/apps/tesm/src/tesm/services/switchcli.py +++ b/apps/tesm/src/tesm/services/switchcli.py @@ -38,6 +38,12 @@ ERROR_RE = re.compile( r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE ) +#: Der Weg nach draussen -- zweimal "exit" fuehrt aus jedem Modus in den +#: privilegierten zurueck, das dritte beendet die Sitzung. Genau so macht es +#: das bewaehrte Skript der Vorgaengerfassung. +ABMELDUNG = ("exit", "exit", "exit") +ABMELDE_PAUSE = 0.15 + READ_CHUNK = 65536 DEFAULT_COMMAND_TIMEOUT = 20 POE_SETTLE_SECONDS = 2 @@ -104,7 +110,42 @@ class SwitchCli: self.close() def close(self) -> None: - for closer in (self._channel, self._transport): + """Meldet sich ab und legt dann erst auf. + + Der entscheidende Teil, und der einzige, den das ``expect``-Skript der + Fassung 1.2.8 hatte und diese Fassung nicht: ``exit`` bis heraus aus + dem Konfigurationsmodus, dann ``exit``, und auf die Frage + "Do you want to log out (y/n)?" ein ``y``. + + Warum das mehr ist als Hoeflichkeit: ArubaOS erlaubt nur eine Handvoll + gleichzeitiger SSH-Sitzungen (``show ip ssh``, Vorgabe fuenf) und + raeumt eine weggebrochene Verbindung **nicht sofort** ab. Wer die + Verbindung nur fallen laesst, belegt den Platz weiter. Nach ein paar + Vorgaengen -- ein Ueberwachungsdurchlauf mit mehreren Geraeten am + selben Switch reicht -- sind alle Plaetze belegt. Danach kommt eine + neue Sitzung noch durch Anmeldung und Authentisierung, bekommt aber + **keine Shell**: kein Prompt, keine Antwort, nichts. Im Betrieb sieht + das aus wie ein stummer Switch, und der Portzyklus passiert nicht. + + Fehler werden hier geschluckt: eine Abmeldung, die nicht klappt, darf + das Ergebnis des Vorgangs nicht umwerfen -- aufgelegt wird ohnehin. + """ + kanal = self._channel + if kanal is not None: + try: + for zeile in ABMELDUNG: + kanal.send(zeile + "\r\n") + # Kurz warten, damit der Switch die Zeile verarbeitet -- + # auf die Antwort zu warten wuerde beim Abmelden nur + # Zeitlimits sammeln. + time.sleep(ABMELDE_PAUSE) + # "Do you want to log out (y/n)?" + kanal.send("y\r\n") + time.sleep(ABMELDE_PAUSE) + except Exception: # noqa: BLE001 - die Verbindung ist ohnehin am Ende + logger.debug("Abmeldung von %s nicht mehr moeglich", self.host) + + for closer in (kanal, self._transport): try: if closer is not None: closer.close() @@ -116,9 +157,26 @@ class SwitchCli: # ------------------------------------------------------------------ # def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str: + """Liest bis zum Prompt. Ohne Prompt ist es ein Fehler, keine Antwort. + + Bis 2.8.2 lief diese Schleife nach dem Zeitlimit einfach aus und gab + zurueck, was bis dahin gekommen war -- oft nichts. Der Aufrufer sah + eine leere Antwort und machte weiter: ``configure terminal`` ins + Nichts, ``no power-over-ethernet`` ins Nichts, und weil die + Zustandsabfrage dann "unknown" ergab und "unknown" als + "nicht widersprochen" durchging, meldete TESM am Ende **Erfolg** -- + waehrend am Switch nichts geschaltet wurde. Genau das war im Betrieb + zu sehen: der Client blieb durchgehend erreichbar, das Protokoll sagte + "durchgefuehrt". + + Ein Kommando ohne Prompt ist ein Kommando ohne Antwort. Das ist ein + Fehlschlag und wird als solcher gemeldet -- mit dem, was zuletzt + hereinkam, denn daran erkennt man, wo die Sitzung steht. + """ assert self._channel is not None deadline = time.monotonic() + timeout collected = b"" + prompt_gesehen = False while time.monotonic() < deadline: if self._channel.recv_ready(): chunk = self._channel.recv(READ_CHUNK) @@ -130,6 +188,7 @@ class SwitchCli: collected = PAGER_RE.sub(b"", collected) continue if PROMPT_RE.search(collected[-200:]): + prompt_gesehen = True break # Der Login-Banner mancher Geraete verlangt eine Bestaetigung. if initial and collected.rstrip().endswith(b"continue"): @@ -138,6 +197,19 @@ class SwitchCli: time.sleep(0.05) text = collected.decode("utf-8", errors="replace") self.transcript.append(text) + if not prompt_gesehen: + letzte = " ".join(text.split())[-200:] + if initial: + raise SshError( + f"{self.host} hat sich nach der Anmeldung nicht gemeldet " + f"(kein Prompt innerhalb von {timeout}s)." + + (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.") + ) + raise SshError( + f"{self.host} hat auf das Kommando nicht geantwortet " + f"(kein Prompt innerhalb von {timeout}s)." + + (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.") + ) return text def send( @@ -212,6 +284,17 @@ class SwitchCli: ) result.transcript = self.full_transcript return result + if before == "enabled" and off_state == "unknown": + # Vorher lesbar, nachher nicht: dann ist nicht die Firmware + # unbekannt, sondern die Sitzung aus dem Tritt. Das als Erfolg + # zu zaehlen war der Fehler, der einen Neustart meldete, den + # es nicht gab. + result.error = ( + "Der Zustand des Ports ließ sich nach dem Abschalten nicht mehr " + "lesen – vorher ging es. Der Portzyklus ist damit nicht belegt." + ) + result.transcript = self.full_transcript + return result result.add(f"Zustand im ausgeschalteten Zustand: {off_state}") self.set_poe(port, enabled=True) @@ -224,6 +307,17 @@ class SwitchCli: result.transcript = self.full_transcript return result + if before == "unknown" and off_state == "unknown" and after == "unknown": + # Kein einziger Zustand lesbar: die Kommandos hat der Switch + # zwar mit einem Prompt bestaetigt (sonst waere die Sitzung + # oben abgebrochen), aber belegt ist der Zyklus nicht. Das + # gehoert in die Meldung -- "durchgefuehrt" waere zu viel + # gesagt. + result.add( + "Der Zustand war nicht lesbar – die Kommandos hat der Switch " + "angenommen, bestätigt ist der Zyklus damit nicht." + ) + result.ok = True except SshError as exc: result.error = str(exc)