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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
+172
-91
@@ -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,73 +2595,142 @@ 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.
|
||||||
"""
|
"""
|
||||||
ws.send(message)
|
try:
|
||||||
time.sleep(0.15)
|
ws.send(message)
|
||||||
ws.close()
|
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):
|
def _terminal_read_line(ws, echo=True):
|
||||||
"""Wird geworfen, wenn der Nutzer einen unbekannten Host-Key ablehnt."""
|
"""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).
|
||||||
class _InteractiveHostKeyPolicy(paramiko.MissingHostKeyPolicy):
|
echo=False unterdrückt das lokale Echo (Passwort-Eingabe), genau wie
|
||||||
"""
|
ein normales ssh-CLI beim Tippen des Passworts nichts anzeigt."""
|
||||||
Emuliert das bekannte SSH-CLI-Verhalten bei unbekannten Host-Keys
|
line = ""
|
||||||
("Are you sure you want to continue connecting?"), aber interaktiv über
|
while True:
|
||||||
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
|
raw = ws.receive(timeout=120)
|
||||||
# Änderungen nicht zu überschreiben) — existiert sie noch nicht,
|
except ConnectionClosed:
|
||||||
# scheitert das mit FileNotFoundError. Leere Datei vorab anlegen.
|
return None
|
||||||
if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
|
if raw is None:
|
||||||
open(SSH_KNOWN_HOSTS_PATH, "a").close()
|
return None
|
||||||
client.save_host_keys(SSH_KNOWN_HOSTS_PATH)
|
try:
|
||||||
except OSError:
|
payload = json.loads(raw)
|
||||||
app.logger.error("save_host_keys failed:\n%s", traceback.format_exc())
|
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):
|
|
||||||
"""
|
def _verify_host_key_interactive(ws, host, port, server_key):
|
||||||
Liest zeichenweise vom Terminal (wie normale Tastatureingaben), bis
|
"""Emuliert das bekannte SSH-CLI-Verhalten bei Host-Keys — unbekannt:
|
||||||
Enter kommt, mit lokalem Echo — es existiert an dieser Stelle noch
|
interaktiv über das Browser-Terminal nachfragen (wie "Are you sure you
|
||||||
keine Remote-Shell, die das für uns übernehmen könnte.
|
want to continue connecting?"); GEÄNDERT (Eintrag vorhanden, aber
|
||||||
"""
|
anderer Key): hart ablehnen ohne Rückfrage, wie ein normales ssh-CLI
|
||||||
answer = ""
|
bei einer möglichen MITM-Situation. Gibt True zurück, wenn die
|
||||||
while True:
|
Verbindung fortgesetzt werden darf."""
|
||||||
raw = self.ws.receive(timeout=120)
|
lookup_host = host if port == 22 else f"[{host}]:{port}"
|
||||||
if raw is None:
|
hostkeys = paramiko.HostKeys()
|
||||||
return False
|
try:
|
||||||
try:
|
hostkeys.load(SSH_KNOWN_HOSTS_PATH)
|
||||||
payload = json.loads(raw)
|
except (OSError, paramiko.SSHException):
|
||||||
except (TypeError, ValueError):
|
pass
|
||||||
continue
|
|
||||||
if payload.get("type") != "input":
|
existing = hostkeys.lookup(lookup_host)
|
||||||
continue
|
if existing and existing.get(server_key.get_name()) and existing[server_key.get_name()] != server_key:
|
||||||
for ch in str(payload.get("data", "")):
|
ws.send(
|
||||||
if ch in ("\r", "\n"):
|
f"\r\n\x1b[31mWARNUNG: Host-Key für '{lookup_host}' hat sich geändert "
|
||||||
self.ws.send("\r\n")
|
f"— Verbindung abgelehnt (möglicher Man-in-the-Middle).\x1b[0m\r\n"
|
||||||
return answer.strip().lower() in ("yes", "y")
|
)
|
||||||
elif ch in ("\x7f", "\b"):
|
return False
|
||||||
if answer:
|
if hostkeys.check(lookup_host, server_key):
|
||||||
answer = answer[:-1]
|
return True
|
||||||
self.ws.send("\b \b")
|
|
||||||
else:
|
fingerprint = ":".join(f"{b:02x}" for b in server_key.get_fingerprint())
|
||||||
answer += ch
|
ws.send(
|
||||||
self.ws.send(ch)
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -236,39 +236,45 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if option_defs and can_edit %}
|
|
||||||
<div class="modal-overlay" id="deviceOptionsModal{{ loop.index }}">
|
|
||||||
<div class="modal">
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" name="save_dhcp_device_options" value="1">
|
|
||||||
<input type="hidden" name="mac" value="{{ r.mac }}">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>DHCP-Optionen — {{ r.name }}</h3>
|
|
||||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p class="text-faint" style="font-size:11.5px; margin:0 0 14px;">Leer lassen, um den globalen Wert zu übernehmen.</p>
|
|
||||||
{% for d in option_defs %}
|
|
||||||
<div class="field">
|
|
||||||
<label>{{ d.name }} <span class="text-faint">(Code {{ d.code }})</span></label>
|
|
||||||
<input type="text" name="opt_{{ d.id }}" value="{{ option_values.get(d.id, {}).get(r.mac, '') }}"
|
|
||||||
placeholder="{{ option_values.get(d.id, {}).get('', '(kein globaler Wert)') }}">
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
||||||
|
{% for r in reservations %}
|
||||||
|
<div class="modal-overlay" id="deviceOptionsModal{{ loop.index }}">
|
||||||
|
<div class="modal">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="save_dhcp_device_options" value="1">
|
||||||
|
<input type="hidden" name="mac" value="{{ r.mac }}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>DHCP-Optionen — {{ r.name }}</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:0 0 14px;">Leer lassen, um den globalen Wert zu übernehmen.</p>
|
||||||
|
{% for d in option_defs %}
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ d.name }} <span class="text-faint">(Code {{ d.code }})</span></label>
|
||||||
|
<input type="text" name="opt_{{ d.id }}" value="{{ option_values.get(d.id, {}).get(r.mac, '') }}"
|
||||||
|
placeholder="{{ option_values.get(d.id, {}).get('', '(kein globaler Wert)') }}">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
{% 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 %}
|
||||||
|
|||||||
@@ -75,66 +75,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{% if can_edit %}
|
|
||||||
<div class="modal-overlay" id="editSwitchModal{{ loop.index }}">
|
|
||||||
<div class="modal">
|
|
||||||
<form method="post" onsubmit="return validateSwitchForm(this, 'edit{{ loop.index }}');">
|
|
||||||
<input type="hidden" name="edit_switch" value="1">
|
|
||||||
<input type="hidden" name="old_hostname" value="{{ s['hostname'] }}">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Switch bearbeiten</h3>
|
|
||||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="field"><label>Hostname</label>
|
|
||||||
<input type="text" name="hostname" value="{{ s['hostname'] }}" required>
|
|
||||||
</div>
|
|
||||||
<div class="field"><label>IP-Adresse</label>
|
|
||||||
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
|
|
||||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
|
||||||
</div>
|
|
||||||
<div class="field"><label>SSH-Port</label>
|
|
||||||
<input type="number" name="ssh_port" value="{{ s['ssh_port'] or '' }}" min="1" max="65535" placeholder="22 (Standard)">
|
|
||||||
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Zugangsdaten</label>
|
|
||||||
<select name="credential_choice" class="credential-select" onchange="toggleNewCredentialFields(this)">
|
|
||||||
{% for c in all_credentials %}
|
|
||||||
<option value="{{ c['id'] }}" data-username="{{ c['username'] }}" {% if c['id'] == s['credential_id'] %}selected{% endif %}>{{ c['name'] }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
<option value="new">+ Neue Zugangsdaten anlegen</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="new-credential-fields hidden">
|
|
||||||
<div class="field"><label>Name der Zugangsdaten</label>
|
|
||||||
<input type="text" name="new_credential_name" placeholder="z.B. Lager-Switche">
|
|
||||||
</div>
|
|
||||||
<div class="field"><label>Username</label>
|
|
||||||
<input type="text" name="new_credential_username" placeholder="z.B. admin">
|
|
||||||
</div>
|
|
||||||
<div class="field"><label>Passwort</label>
|
|
||||||
<input type="password" id="password_edit{{ loop.index }}" name="new_credential_password">
|
|
||||||
</div>
|
|
||||||
<div class="field"><label>Passwort bestätigen</label>
|
|
||||||
<input type="password" id="password_confirm_edit{{ loop.index }}" name="new_credential_password_confirm">
|
|
||||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
|
|
||||||
Verbindung testen
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr class="empty-row"><td colspan="5">Noch keine Switche vorhanden.</td></tr>
|
<tr class="empty-row"><td colspan="5">Noch keine Switche vorhanden.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -143,6 +83,73 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 %}
|
||||||
|
{% for s in switches %}
|
||||||
|
<div class="modal-overlay" id="editSwitchModal{{ loop.index }}">
|
||||||
|
<div class="modal" style="max-width:1000px;">
|
||||||
|
<form method="post" onsubmit="return validateSwitchForm(this, 'edit{{ loop.index }}');">
|
||||||
|
<input type="hidden" name="edit_switch" value="1">
|
||||||
|
<input type="hidden" name="old_hostname" value="{{ s['hostname'] }}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Switch bearbeiten</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field"><label>Hostname</label>
|
||||||
|
<input type="text" name="hostname" value="{{ s['hostname'] }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>IP-Adresse</label>
|
||||||
|
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
|
||||||
|
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>SSH-Port</label>
|
||||||
|
<input type="number" name="ssh_port" value="{{ s['ssh_port'] or '' }}" min="1" max="65535" placeholder="22 (Standard)">
|
||||||
|
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Zugangsdaten</label>
|
||||||
|
<select name="credential_choice" class="credential-select" onchange="toggleNewCredentialFields(this)">
|
||||||
|
{% for c in all_credentials %}
|
||||||
|
<option value="{{ c['id'] }}" data-username="{{ c['username'] }}" {% if c['id'] == s['credential_id'] %}selected{% endif %}>{{ c['name'] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
<option value="new">+ Neue Zugangsdaten anlegen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="new-credential-fields hidden">
|
||||||
|
<div class="field"><label>Name der Zugangsdaten</label>
|
||||||
|
<input type="text" name="new_credential_name" placeholder="z.B. Lager-Switche">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Username</label>
|
||||||
|
<input type="text" name="new_credential_username" placeholder="z.B. admin">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Passwort</label>
|
||||||
|
<input type="password" id="password_edit{{ loop.index }}" name="new_credential_password">
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Passwort bestätigen</label>
|
||||||
|
<input type="password" id="password_confirm_edit{{ loop.index }}" name="new_credential_password_confirm">
|
||||||
|
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
|
||||||
|
Verbindung testen
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if can_create %}
|
{% if can_create %}
|
||||||
<!-- Modal: Neuer Switch -->
|
<!-- Modal: Neuer Switch -->
|
||||||
<div class="modal-overlay" id="addSwitchModal">
|
<div class="modal-overlay" id="addSwitchModal">
|
||||||
|
|||||||
Reference in New Issue
Block a user