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,
|
||||
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
|
||||
|
||||
|
||||
+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_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
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -236,39 +236,45 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</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 %}
|
||||
</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 %}
|
||||
{% 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 %}
|
||||
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px;">Keine aktiven Clients mit MAC + IP vorhanden.</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -75,66 +75,6 @@
|
||||
</div>
|
||||
</td>
|
||||
</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 %}
|
||||
<tr class="empty-row"><td colspan="5">Noch keine Switche vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
@@ -143,6 +83,73 @@
|
||||
</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 %}
|
||||
<!-- Modal: Neuer Switch -->
|
||||
<div class="modal-overlay" id="addSwitchModal">
|
||||
|
||||
Reference in New Issue
Block a user