@@ -38,6 +38,12 @@ ERROR_RE = re.compile(
r " (invalid input|unknown command|incomplete|ambiguous|not supported| % *e rror) " , re . IGNORECASE
r " (invalid input|unknown command|incomplete|ambiguous|not supported| % *e rror) " , 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
READ_CHUNK = 65536
DEFAULT_COMMAND_TIMEOUT = 20
DEFAULT_COMMAND_TIMEOUT = 20
POE_SETTLE_SECONDS = 2
POE_SETTLE_SECONDS = 2
@@ -104,7 +110,42 @@ class SwitchCli:
self . close ( )
self . close ( )
def close ( self ) - > None :
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 :
try :
if closer is not None :
if closer is not None :
closer . close ( )
closer . close ( )
@@ -116,9 +157,26 @@ class SwitchCli:
# ------------------------------------------------------------------ #
# ------------------------------------------------------------------ #
def _read_until_prompt ( self , * , timeout : int , initial : bool = False ) - > str :
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
assert self . _channel is not None
deadline = time . monotonic ( ) + timeout
deadline = time . monotonic ( ) + timeout
collected = b " "
collected = b " "
prompt_gesehen = False
while time . monotonic ( ) < deadline :
while time . monotonic ( ) < deadline :
if self . _channel . recv_ready ( ) :
if self . _channel . recv_ready ( ) :
chunk = self . _channel . recv ( READ_CHUNK )
chunk = self . _channel . recv ( READ_CHUNK )
@@ -130,6 +188,7 @@ class SwitchCli:
collected = PAGER_RE . sub ( b " " , collected )
collected = PAGER_RE . sub ( b " " , collected )
continue
continue
if PROMPT_RE . search ( collected [ - 200 : ] ) :
if PROMPT_RE . search ( collected [ - 200 : ] ) :
prompt_gesehen = True
break
break
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
if initial and collected . rstrip ( ) . endswith ( b " continue " ) :
if initial and collected . rstrip ( ) . endswith ( b " continue " ) :
@@ -138,6 +197,19 @@ class SwitchCli:
time . sleep ( 0.05 )
time . sleep ( 0.05 )
text = collected . decode ( " utf-8 " , errors = " replace " )
text = collected . decode ( " utf-8 " , errors = " replace " )
self . transcript . append ( text )
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
return text
def send (
def send (
@@ -212,6 +284,17 @@ class SwitchCli:
)
)
result . transcript = self . full_transcript
result . transcript = self . full_transcript
return result
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 } " )
result . add ( f " Zustand im ausgeschalteten Zustand: { off_state } " )
self . set_poe ( port , enabled = True )
self . set_poe ( port , enabled = True )
@@ -224,6 +307,17 @@ class SwitchCli:
result . transcript = self . full_transcript
result . transcript = self . full_transcript
return result
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
result . ok = True
except SshError as exc :
except SshError as exc :
result . error = str ( exc )
result . error = str ( exc )