Compare commits

...
2 Commits
4 changed files with 244 additions and 3 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.8.2"
version = "2.8.4"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+24 -1
View File
@@ -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:
+124
View File
@@ -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",
+95 -1
View File
@@ -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)