diff --git a/README.md b/README.md index 8d7d616..f1748fe 100644 --- a/README.md +++ b/README.md @@ -494,13 +494,6 @@ Voraussetzungen: empfiehlt es sich, `poe_web.service` künftig unter einem eigenen, unprivilegierten Systembenutzer laufen zu lassen (im Original wie auch hier 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 diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index 1808356..3fdaa7b 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -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_bcrypt import Bcrypt from flask_sock import Sock +from simple_websocket.errors import ConnectionClosed from werkzeug.utils import secure_filename from cryptography.fernet import Fernet from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC @@ -2594,73 +2595,142 @@ def _send_and_close(ws, message): von der Schließung der Verbindung als Frame-Fehler gewertet und die Nachricht verworfen wird, bevor sie angezeigt werden kann. """ - ws.send(message) - time.sleep(0.15) - ws.close() + try: + ws.send(message) + time.sleep(0.15) + except ConnectionClosed: + pass # Client ist schon weg — nichts mehr zuzustellen, kein Fehlerfall. + try: + ws.close() + except Exception: + pass -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) +def _terminal_read_line(ws, echo=True): + """Liest zeichenweise vom Terminal (wie normale Tastatureingaben), bis + Enter kommt — für Prompts, bevor eine Remote-Shell existiert, die das + selbst übernehmen könnte (Host-Key-Bestätigung, Passwortabfrage). + echo=False unterdrückt das lokale Echo (Passwort-Eingabe), genau wie + ein normales ssh-CLI beim Tippen des Passworts nichts anzeigt.""" + line = "" + while True: try: - # save_host_keys() lädt die Datei intern zuerst neu (um parallele - # Änderungen nicht zu überschreiben) — existiert sie noch nicht, - # scheitert das mit FileNotFoundError. Leere Datei vorab anlegen. - 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()) + raw = ws.receive(timeout=120) + except ConnectionClosed: + return None + if raw is None: + return None + try: + payload = json.loads(raw) + except (TypeError, ValueError): + continue + if payload.get("type") != "input": + continue + for ch in str(payload.get("data", "")): + if ch in ("\r", "\n"): + ws.send("\r\n") + return line + elif ch in ("\x7f", "\b"): + if line: + line = line[:-1] + if echo: + ws.send("\b \b") + elif ch == "\x03": # Strg+C + ws.send("^C\r\n") + return None + else: + line += ch + if echo: + ws.send(ch) - def _read_yes_no(self): - """ - Liest zeichenweise vom Terminal (wie normale Tastatureingaben), bis - Enter kommt, mit lokalem Echo — es existiert an dieser Stelle noch - keine Remote-Shell, die das für uns übernehmen könnte. - """ - answer = "" - while True: - raw = self.ws.receive(timeout=120) - if raw is None: - return False - try: - payload = json.loads(raw) - except (TypeError, ValueError): - continue - if payload.get("type") != "input": - continue - for ch in str(payload.get("data", "")): - if ch in ("\r", "\n"): - self.ws.send("\r\n") - return answer.strip().lower() in ("yes", "y") - elif ch in ("\x7f", "\b"): - if answer: - answer = answer[:-1] - self.ws.send("\b \b") - else: - answer += ch - self.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") @@ -2687,51 +2757,62 @@ def _ssh_terminal_inner(ws): host = str(init.get("host") or "").strip() username = str(init.get("username") or "").strip() - password = str(init.get("password") or "") - port = str(init.get("port") or "22").strip() + port_str = 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") return + port = int(port_str) 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: - client.load_host_keys(SSH_KNOWN_HOSTS_PATH) - except (OSError, paramiko.SSHException): - pass - client.set_missing_host_key_policy(_InteractiveHostKeyPolicy(ws)) + sock_conn = socket.create_connection((host, port), timeout=10) + except OSError as e: + _send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n") + 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: - client.connect( - 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 + authenticated = _authenticate_interactive(ws, transport, username) 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") 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") 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: _send_and_close(ws, f"\r\n\x1b[31m✗ Konnte keine Shell öffnen: {e}\x1b[0m\r\n") - client.close() + transport.close() return channel.settimeout(0.0) @@ -2787,7 +2868,7 @@ def _ssh_terminal_inner(ws): except Exception: pass try: - client.close() + transport.close() except Exception: pass diff --git a/srv/poe_manager/static/js/app.js b/srv/poe_manager/static/js/app.js index e7df164..dfb1128 100644 --- a/srv/poe_manager/static/js/app.js +++ b/srv/poe_manager/static/js/app.js @@ -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 ---------------- */ document.addEventListener("DOMContentLoaded", function () { @@ -399,7 +378,6 @@ initCheckTimer(); initRunCheckNow(); initSortableTables(); - initSourceProtection(); document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); }); diff --git a/srv/poe_manager/templates/settings_dhcp.html b/srv/poe_manager/templates/settings_dhcp.html index a5d1dd2..127d6f5 100644 --- a/srv/poe_manager/templates/settings_dhcp.html +++ b/srv/poe_manager/templates/settings_dhcp.html @@ -236,39 +236,45 @@ {% endif %} - {% if option_defs and can_edit %} -
- {% endif %} {% endfor %} + + {% if option_defs and can_edit %} + {% for r in reservations %} + + {% endfor %} + {% endif %} {% else %}Keine aktiven Clients mit MAC + IP vorhanden.
{% endif %} diff --git a/srv/poe_manager/templates/switches.html b/srv/poe_manager/templates/switches.html index 17be10d..b62fac1 100644 --- a/srv/poe_manager/templates/switches.html +++ b/srv/poe_manager/templates/switches.html @@ -75,66 +75,6 @@ - - {% if can_edit %} - - {% endif %} {% else %}