Fix: SSH-Terminal-Login (No auth methods), kaputtes DOM in Bearbeiten-Modals

Quelltext-Huerde wieder entfernt (auf Wunsch) - siehe vorherigen Commit,
hier nur der Revert von initSourceProtection() und der README-Erwaehnung.

SSH-Terminal-Login war komplett kaputt (von dir gemeldet + Screenshot):
- Root Cause: das Init-Payload vom Browser enthielt nie ein Passwort,
  SSHClient.connect() bekam also weder Passwort noch Key noch Agent und
  scheiterte sofort mit "No authentication methods available" - noch
  bevor ueberhaupt eine interaktive Passwortabfrage moeglich gewesen
  waere (die High-Level-API erledigt Host-Key-Pruefung UND
  Authentifizierung in einem blockierenden Aufruf).
- Fix: Umstieg auf die Low-Level paramiko.Transport-API. Nach
  Host-Key-Bestaetigung wird aktiv erfragt, welche Auth-Methoden der
  Server anbietet (auth_none), und bei Bedarf interaktiv ueber das
  Browser-Terminal nach Passwort/keyboard-interactive-Prompts gefragt
  -- genau das Verhalten, das der bestehende Hinweistext im Modal schon
  immer versprach, aber nie tatsaechlich implementiert war.
  Host-Key-Verifikation dabei manuell nachgebaut (_verify_host_key_interactive)
  inkl. hartem Ablehnen bei GEAENDERTEM (nicht nur unbekanntem) Host-Key,
  wie ein echtes ssh-CLI bei einer moeglichen MITM-Situation.
- Waehrend der Live-Verifikation gegen ein echtes Geraet zwei weitere
  Bugs gefunden und gefixt: ws.receive() wirft in diesem Setup
  ConnectionClosed statt None zurueckzugeben (crashte
  _terminal_read_line unbehandelt -> "Invalid frame header" beim
  Client); _send_and_close() crashte ebenso, wenn der Client bereits weg
  war. Beide jetzt defensiv abgefangen.
- Live gegen ein echtes Zielgeraet verifiziert (Host-Key-Bestaetigung,
  Passwort-Prompt, erfolgreicher Login) sowie manuell von dir bestaetigt.

Kaputtes DOM in zwei Bearbeiten-Modals (von dir gemeldet: "Verbindung
testen" oeffnete beim Switch bearbeiten kein Fenster, obwohl es beim
Neuanlegen funktionierte):
- Root Cause: <div class="modal-overlay">...</div> stand direkt in
  <tbody> (nur <tr> ist dort gueltig). Browser "foster-parenten"
  ungueltigen Tbody-Inhalt aus der Tabelle heraus und zerreissen dabei
  teils die Eltern-Kind-Beziehung zwischen <form> und seinen Buttons --
  this.closest("form") lieferte dadurch null statt des Formulars.
  Betroffen: editSwitchModal (switches.html), deviceOptionsModal
  (settings_dhcp.html). Fix: beide Modal-Bloecke aus der Tabelle heraus
  in eine eigene Schleife direkt danach verschoben (gleiches Muster wie
  die bereits korrekten Neuanlegen-Modals).
- Per DOM-Inspektion verifiziert: this.closest("form") lieferte vorher
  null, danach das korrekte Formular fuer alle Zeilen; End-to-End-Test
  bestaetigt, dass sich das Terminal-Modal jetzt oeffnet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:00:22 +02:00
co-authored by Claude Sonnet 5
parent 6803929140
commit 4d7433e832
5 changed files with 273 additions and 208 deletions
-7
View File
@@ -494,13 +494,6 @@ Voraussetzungen:
empfiehlt es sich, `poe_web.service` künftig unter einem eigenen, empfiehlt es sich, `poe_web.service` künftig unter einem eigenen,
unprivilegierten Systembenutzer laufen zu lassen (im Original wie auch hier unprivilegierten Systembenutzer laufen zu lassen (im Original wie auch hier
aktuell `root`, um Änderungen am produktiven Setup nicht zu vergrößern). aktuell `root`, um Änderungen am produktiven Setup nicht zu vergrößern).
- Rechtsklick/DevTools-Shortcuts (F12, Strg+Umschalt+I/J/C, Strg+U) sind per
JS blockiert (`initSourceProtection()` in `app.js`). Das ist eine Hürde
gegen gelegentliches Kopieren, **keine echte Sicherheit** — der Browser
liefert HTML/CSS/JS immer vollständig aus (sonst könnte er die Seite
nicht darstellen), und jede dieser Sperren lässt sich in Sekunden umgehen
(URL-Leiste, externer HTTP-Client, DevTools per Menü). Die eigentliche
Absicherung sind ausschließlich die serverseitigen Rechteprüfungen.
## Projektstruktur ## Projektstruktur
+160 -79
View File
@@ -12,6 +12,7 @@ from flask import Flask, render_template, request, redirect, url_for, flash, jso
from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user
from flask_bcrypt import Bcrypt from flask_bcrypt import Bcrypt
from flask_sock import Sock from flask_sock import Sock
from simple_websocket.errors import ConnectionClosed
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
@@ -2594,56 +2595,31 @@ def _send_and_close(ws, message):
von der Schließung der Verbindung als Frame-Fehler gewertet und die von der Schließung der Verbindung als Frame-Fehler gewertet und die
Nachricht verworfen wird, bevor sie angezeigt werden kann. Nachricht verworfen wird, bevor sie angezeigt werden kann.
""" """
try:
ws.send(message) ws.send(message)
time.sleep(0.15) time.sleep(0.15)
ws.close() except ConnectionClosed:
pass # Client ist schon weg — nichts mehr zuzustellen, kein Fehlerfall.
class _AbortHostKey(Exception):
"""Wird geworfen, wenn der Nutzer einen unbekannten Host-Key ablehnt."""
class _InteractiveHostKeyPolicy(paramiko.MissingHostKeyPolicy):
"""
Emuliert das bekannte SSH-CLI-Verhalten bei unbekannten Host-Keys
("Are you sure you want to continue connecting?"), aber interaktiv über
das Browser-Terminal statt eines lokalen TTY.
"""
def __init__(self, ws):
self.ws = ws
def missing_host_key(self, client, hostname, key):
fingerprint = ":".join(f"{b:02x}" for b in key.get_fingerprint())
self.ws.send(
f"\r\nDie Authentizität von Host '{hostname}' kann nicht festgestellt werden.\r\n"
f"{key.get_name()}-Key-Fingerabdruck: {fingerprint}\r\n"
f"Trotzdem verbinden (yes/no)? "
)
if not self._read_yes_no():
raise _AbortHostKey()
client.get_host_keys().add(hostname, key.get_name(), key)
try: try:
# save_host_keys() lädt die Datei intern zuerst neu (um parallele ws.close()
# Änderungen nicht zu überschreiben) — existiert sie noch nicht, except Exception:
# scheitert das mit FileNotFoundError. Leere Datei vorab anlegen. pass
if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
open(SSH_KNOWN_HOSTS_PATH, "a").close()
client.save_host_keys(SSH_KNOWN_HOSTS_PATH)
except OSError:
app.logger.error("save_host_keys failed:\n%s", traceback.format_exc())
def _read_yes_no(self):
""" def _terminal_read_line(ws, echo=True):
Liest zeichenweise vom Terminal (wie normale Tastatureingaben), bis """Liest zeichenweise vom Terminal (wie normale Tastatureingaben), bis
Enter kommt, mit lokalem Echo — es existiert an dieser Stelle noch Enter kommt — für Prompts, bevor eine Remote-Shell existiert, die das
keine Remote-Shell, die das für uns übernehmen könnte. selbst übernehmen könnte (Host-Key-Bestätigung, Passwortabfrage).
""" echo=False unterdrückt das lokale Echo (Passwort-Eingabe), genau wie
answer = "" ein normales ssh-CLI beim Tippen des Passworts nichts anzeigt."""
line = ""
while True: while True:
raw = self.ws.receive(timeout=120) try:
raw = ws.receive(timeout=120)
except ConnectionClosed:
return None
if raw is None: if raw is None:
return False return None
try: try:
payload = json.loads(raw) payload = json.loads(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -2652,15 +2628,109 @@ class _InteractiveHostKeyPolicy(paramiko.MissingHostKeyPolicy):
continue continue
for ch in str(payload.get("data", "")): for ch in str(payload.get("data", "")):
if ch in ("\r", "\n"): if ch in ("\r", "\n"):
self.ws.send("\r\n") ws.send("\r\n")
return answer.strip().lower() in ("yes", "y") return line
elif ch in ("\x7f", "\b"): elif ch in ("\x7f", "\b"):
if answer: if line:
answer = answer[:-1] line = line[:-1]
self.ws.send("\b \b") if echo:
ws.send("\b \b")
elif ch == "\x03": # Strg+C
ws.send("^C\r\n")
return None
else: else:
answer += ch line += ch
self.ws.send(ch) if echo:
ws.send(ch)
def _verify_host_key_interactive(ws, host, port, server_key):
"""Emuliert das bekannte SSH-CLI-Verhalten bei Host-Keys — unbekannt:
interaktiv über das Browser-Terminal nachfragen (wie "Are you sure you
want to continue connecting?"); GEÄNDERT (Eintrag vorhanden, aber
anderer Key): hart ablehnen ohne Rückfrage, wie ein normales ssh-CLI
bei einer möglichen MITM-Situation. Gibt True zurück, wenn die
Verbindung fortgesetzt werden darf."""
lookup_host = host if port == 22 else f"[{host}]:{port}"
hostkeys = paramiko.HostKeys()
try:
hostkeys.load(SSH_KNOWN_HOSTS_PATH)
except (OSError, paramiko.SSHException):
pass
existing = hostkeys.lookup(lookup_host)
if existing and existing.get(server_key.get_name()) and existing[server_key.get_name()] != server_key:
ws.send(
f"\r\n\x1b[31mWARNUNG: Host-Key für '{lookup_host}' hat sich geändert "
f"— Verbindung abgelehnt (möglicher Man-in-the-Middle).\x1b[0m\r\n"
)
return False
if hostkeys.check(lookup_host, server_key):
return True
fingerprint = ":".join(f"{b:02x}" for b in server_key.get_fingerprint())
ws.send(
f"\r\nDie Authentizität von Host '{lookup_host}' kann nicht festgestellt werden.\r\n"
f"{server_key.get_name()}-Key-Fingerabdruck: {fingerprint}\r\n"
f"Trotzdem verbinden (yes/no)? "
)
answer = _terminal_read_line(ws, echo=True)
if not answer or answer.strip().lower() not in ("yes", "y"):
return False
hostkeys.add(lookup_host, server_key.get_name(), server_key)
try:
if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
open(SSH_KNOWN_HOSTS_PATH, "a").close()
hostkeys.save(SSH_KNOWN_HOSTS_PATH)
except OSError:
app.logger.error("save_host_keys failed:\n%s", traceback.format_exc())
return True
def _authenticate_interactive(ws, transport, username):
"""Fragt Zugangsdaten genauso ab, wie es ein echtes ssh-CLI täte —
direkt über die WebSocket-Verbindung, da an dieser Stelle noch keine
Remote-Shell existiert, in die man tippen könnte. Vorher wurde hier nie
tatsächlich ein Passwort abgefragt (das Init-Payload vom Browser enthält
gar keins) — SSHClient.connect() ohne password/pkey/agent scheitert
sofort mit "No authentication methods available", bevor überhaupt eine
Passwortabfrage möglich wäre. Fragt daher aktiv nach, welche
Auth-Methoden der Server anbietet, und fordert bei Bedarf interaktiv
zur Eingabe auf (Passwort oder keyboard-interactive)."""
allowed = []
try:
transport.auth_none(username)
except paramiko.BadAuthenticationType as e:
allowed = e.allowed_types
except paramiko.SSHException:
allowed = []
if transport.is_authenticated():
return True
if "keyboard-interactive" in allowed:
def handler(title, instructions, prompt_list):
responses = []
for prompt_text, echo in prompt_list:
ws.send(f"\r\n{prompt_text}")
responses.append(_terminal_read_line(ws, echo=bool(echo)) or "")
return responses
try:
transport.auth_interactive(username, handler)
except paramiko.AuthenticationException:
pass
elif "password" in allowed:
ws.send("\r\nPasswort: ")
pw = _terminal_read_line(ws, echo=False)
try:
transport.auth_password(username, pw or "")
except paramiko.AuthenticationException:
pass
else:
ws.send(f"\r\n\x1b[31mServer bietet keine unterstützte Auth-Methode an ({', '.join(allowed) or 'keine'}).\x1b[0m\r\n")
return transport.is_authenticated()
@sock.route("/ws/ssh_terminal") @sock.route("/ws/ssh_terminal")
@@ -2687,51 +2757,62 @@ def _ssh_terminal_inner(ws):
host = str(init.get("host") or "").strip() host = str(init.get("host") or "").strip()
username = str(init.get("username") or "").strip() username = str(init.get("username") or "").strip()
password = str(init.get("password") or "") port_str = str(init.get("port") or "22").strip()
port = str(init.get("port") or "22").strip()
if not (_SSH_HOST_RE.match(host) and _SSH_USER_RE.match(username) and port.isdigit()): if not (_SSH_HOST_RE.match(host) and _SSH_USER_RE.match(username) and port_str.isdigit()):
_send_and_close(ws, "\r\n\x1b[31mUngültiger Host oder Benutzername.\x1b[0m\r\n") _send_and_close(ws, "\r\n\x1b[31mUngültiger Host oder Benutzername.\x1b[0m\r\n")
return return
port = int(port_str)
ws.send(f"\x1b[90m→ Verbinde zu {username}@{host}:{port}\x1b[0m\r\n") ws.send(f"\x1b[90m→ Verbinde zu {username}@{host}:{port}\x1b[0m\r\n")
client = paramiko.SSHClient() # Bewusst die Low-Level-Transport-API statt SSHClient.connect(): eine
# Passwortabfrage MITTEN im Verbindungsaufbau (nach Host-Key-Bestätigung,
# vor der eigentlichen Shell) ist mit der High-Level-API nicht möglich,
# da connect() Host-Key-Prüfung UND Authentifizierung in einem
# blockierenden Aufruf erledigt. Vorher führte das dazu, dass nie ein
# Passwort abgefragt wurde und jeder Login mit "No authentication
# methods available" scheiterte, sobald kein Passwort im initialen
# Payload mitgeschickt wurde (was nie der Fall war/ist).
try: try:
client.load_host_keys(SSH_KNOWN_HOSTS_PATH) sock_conn = socket.create_connection((host, port), timeout=10)
except (OSError, paramiko.SSHException): except OSError as e:
pass _send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n")
client.set_missing_host_key_policy(_InteractiveHostKeyPolicy(ws)) return
transport = paramiko.Transport(sock_conn)
try:
transport.start_client(timeout=10)
except (paramiko.SSHException, EOFError, OSError) as e:
_send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n")
return
if not _verify_host_key_interactive(ws, host, port, transport.get_remote_server_key()):
transport.close()
_send_and_close(ws, "\r\n\x1b[33mHost-Key abgelehnt oder Verbindung abgebrochen.\x1b[0m\r\n")
return
try: try:
client.connect( authenticated = _authenticate_interactive(ws, transport, username)
hostname=host,
port=int(port),
username=username,
password=password or None,
timeout=10,
banner_timeout=10,
auth_timeout=15,
look_for_keys=False,
allow_agent=False,
)
except _AbortHostKey:
_send_and_close(ws, "\r\n\x1b[33mHost-Key abgelehnt — Verbindung abgebrochen.\x1b[0m\r\n")
return
except paramiko.AuthenticationException:
_send_and_close(ws, "\r\n\x1b[31m✗ Zugangsdaten falsch (Authentifizierung fehlgeschlagen).\x1b[0m\r\n")
return
except (paramiko.SSHException, OSError, EOFError) as e: except (paramiko.SSHException, OSError, EOFError) as e:
transport.close()
_send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n") _send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n")
return return
if not authenticated:
transport.close()
_send_and_close(ws, "\r\n\x1b[31m✗ Zugangsdaten falsch (Authentifizierung fehlgeschlagen).\x1b[0m\r\n")
return
ws.send("\r\n\x1b[32m✓ Login erfolgreich — Sitzung wird geöffnet …\x1b[0m\r\n") ws.send("\r\n\x1b[32m✓ Login erfolgreich — Sitzung wird geöffnet …\x1b[0m\r\n")
try: try:
channel = client.invoke_shell(term="xterm-256color", width=80, height=24) channel = transport.open_session(timeout=10)
channel.get_pty(term="xterm-256color", width=80, height=24)
channel.invoke_shell()
except paramiko.SSHException as e: except paramiko.SSHException as e:
_send_and_close(ws, f"\r\n\x1b[31m✗ Konnte keine Shell öffnen: {e}\x1b[0m\r\n") _send_and_close(ws, f"\r\n\x1b[31m✗ Konnte keine Shell öffnen: {e}\x1b[0m\r\n")
client.close() transport.close()
return return
channel.settimeout(0.0) channel.settimeout(0.0)
@@ -2787,7 +2868,7 @@ def _ssh_terminal_inner(ws):
except Exception: except Exception:
pass pass
try: try:
client.close() transport.close()
except Exception: except Exception:
pass pass
-22
View File
@@ -366,27 +366,6 @@
}); });
} }
/* ---------------- Quelltext-Hürde ----------------
Blockiert Rechtsklick-Menü und die üblichen DevTools-/Quelltext-
Shortcuts. WICHTIG: das ist eine Hürde für gelegentliches Kopieren,
KEIN echter Schutz — jeder Browser liefert HTML/CSS/JS immer
vollständig aus, sonst könnte er die Seite nicht darstellen. Wer
wirklich an den Quelltext will, umgeht das in Sekunden (URL-Leiste,
externer HTTP-Client, DevTools per Menü statt Shortcut). Echte
Sicherheit kommt ausschließlich von den serverseitigen Rechteprüfungen
in app.py, nicht davon, das Nachsehen im Browser zu erschweren. */
function initSourceProtection() {
document.addEventListener("contextmenu", (e) => e.preventDefault());
document.addEventListener("keydown", (e) => {
const key = e.key.toUpperCase();
const blocked =
key === "F12" ||
(e.ctrlKey && e.shiftKey && ["I", "J", "C"].includes(key)) ||
(e.ctrlKey && key === "U");
if (blocked) e.preventDefault();
});
}
/* ---------------- Init ---------------- */ /* ---------------- Init ---------------- */
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
@@ -399,7 +378,6 @@
initCheckTimer(); initCheckTimer();
initRunCheckNow(); initRunCheckNow();
initSortableTables(); initSortableTables();
initSourceProtection();
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
}); });
+11 -5
View File
@@ -236,7 +236,17 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Modals außerhalb der Tabelle: ein <div> direkt in <tbody> ist
ungültiges HTML und wird vom Browser aus der Tabelle heraus
"foster-parented" — dabei kann die Eltern-Kind-Beziehung zwischen
Formular und Feldern zerrissen werden. -->
{% if option_defs and can_edit %} {% if option_defs and can_edit %}
{% for r in reservations %}
<div class="modal-overlay" id="deviceOptionsModal{{ loop.index }}"> <div class="modal-overlay" id="deviceOptionsModal{{ loop.index }}">
<div class="modal"> <div class="modal">
<form method="post"> <form method="post">
@@ -263,12 +273,8 @@
</form> </form>
</div> </div>
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
</tbody> {% endif %}
</table>
</div>
</div>
{% else %} {% else %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px;">Keine aktiven Clients mit MAC + IP vorhanden.</p> <p class="text-faint" style="font-size:12.5px; margin-bottom:18px;">Keine aktiven Clients mit MAC + IP vorhanden.</p>
{% endif %} {% endif %}
+15 -8
View File
@@ -75,10 +75,23 @@
</div> </div>
</td> </td>
</tr> </tr>
{% else %}
<tr class="empty-row"><td colspan="5">Noch keine Switche vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Bearbeiten-Modals außerhalb der Tabelle: ein <div> direkt in <tbody>
ist ungültiges HTML — Browser "foster-parenten" es dann aus der
Tabelle heraus und zerreißen dabei mitunter die Eltern-Kind-Beziehung
zwischen Formular und Buttons (this.closest('form') lieferte dadurch
null, "Verbindung testen" öffnete kein Terminal mehr). -->
{% if can_edit %} {% if can_edit %}
{% for s in switches %}
<div class="modal-overlay" id="editSwitchModal{{ loop.index }}"> <div class="modal-overlay" id="editSwitchModal{{ loop.index }}">
<div class="modal"> <div class="modal" style="max-width:1000px;">
<form method="post" onsubmit="return validateSwitchForm(this, 'edit{{ loop.index }}');"> <form method="post" onsubmit="return validateSwitchForm(this, 'edit{{ loop.index }}');">
<input type="hidden" name="edit_switch" value="1"> <input type="hidden" name="edit_switch" value="1">
<input type="hidden" name="old_hostname" value="{{ s['hostname'] }}"> <input type="hidden" name="old_hostname" value="{{ s['hostname'] }}">
@@ -134,14 +147,8 @@
</form> </form>
</div> </div>
</div> </div>
{% endif %}
{% else %}
<tr class="empty-row"><td colspan="5">Noch keine Switche vorhanden.</td></tr>
{% endfor %} {% endfor %}
</tbody> {% endif %}
</table>
</div>
</div>
{% if can_create %} {% if can_create %}
<!-- Modal: Neuer Switch --> <!-- Modal: Neuer Switch -->