(() => { "use strict"; // Wird beim ersten Aufruf von loadRolesTab() aus GET /admin/roles/names // gefuellt (Teil D.6 Schritt 1: keine Hartkodierung mehr, siehe // Umsetzungsauftrag_Sonnet5.md D.5 -- die Rollenliste soll aus der DB // kommen, nicht aus dem Frontend-Code dupliziert werden). let ROLE_NAMES = []; const bannerBox = document.getElementById("banner-box"); let meInfo = { is_admin: false }; function showBanner(message, type) { const div = document.createElement("div"); div.className = `banner ${type === "error" ? "error" : "ok"}`; div.textContent = message; bannerBox.replaceChildren(div); window.setTimeout(() => { if (bannerBox.contains(div)) bannerBox.removeChild(div); }, 8000); } async function apiFetch(url, options = {}) { const res = await fetch(url, { credentials: "same-origin", ...options }); if (res.status === 401) { window.location.href = "/"; throw new Error("nicht angemeldet"); } let data = null; try { data = await res.json(); } catch (_err) { data = null; } if (!res.ok) { const detail = (data && data.detail) || `Fehler ${res.status}`; throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); } return data; } function getJson(url) { return apiFetch(url); } function sendJson(url, method, body) { return apiFetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); } function el(tag, props = {}, children = []) { const node = document.createElement(tag); for (const [k, v] of Object.entries(props)) { if (k === "className") node.className = v; else if (k === "textContent") node.textContent = v; else node.setAttribute(k, v); } for (const child of children) node.appendChild(child); return node; } function actionButton(label, className, handler) { const btn = el("button", { type: "button", className: `btn-small ${className}` }); btn.textContent = label; btn.addEventListener("click", handler); return btn; } function fillTable(tbody, rows) { tbody.replaceChildren(...rows); } function optionsFromList(items, valueKey, labelFn) { return items.map((item) => { const opt = document.createElement("option"); opt.value = item[valueKey]; opt.textContent = labelFn(item); return opt; }); } function roleCheckboxGrid(container) { container.replaceChildren( ...ROLE_NAMES.map((name) => { const id = `${container.id}-${name}`; const row = el("div", { className: "checkbox-row" }); const checkbox = el("input", { type: "checkbox", id, value: name }); const label = el("label", { for: id, textContent: name }); row.appendChild(checkbox); row.appendChild(label); return row; }) ); } function checkedValues(container) { return Array.from(container.querySelectorAll("input:checked")).map((c) => c.value); } // --------------------------------------------------------------------- // Tabs // --------------------------------------------------------------------- const tabLoaders = { users: loadUsersTab, groups: loadGroupsTab, hostgroups: loadHostsTab, servers: loadHostsTab, credentials: loadCredentialsTab, roles: loadRolesTab, tokens: loadTokensTab, sessions: loadSessionsTab, connlog: loadConnLogTab, audit: loadAuditTab, }; const loadedTabs = new Set(); let currentTab = null; document.getElementById("tabs").addEventListener("click", (ev) => { const btn = ev.target.closest(".tab-btn"); if (!btn) return; const tab = btn.dataset.tab; document.querySelectorAll(".tab-btn").forEach((b) => b.classList.toggle("active", b === btn)); document.querySelectorAll(".tab-panel").forEach((panel) => { panel.classList.toggle("hidden", panel.id !== `tab-${tab}`); }); if (currentTab === "connlog" && tab !== "connlog") disconnectLogStream(); currentTab = tab; if (!loadedTabs.has(tab)) { loadedTabs.add(tab); tabLoaders[tab]().catch((err) => showBanner(err.message, "error")); } else if (tab === "connlog") { connectLogStream(); } else if (tab === "sessions") { refreshSessions().catch((err) => showBanner(err.message, "error")); } }); // --------------------------------------------------------------------- // Benutzer // --------------------------------------------------------------------- async function loadUsersTab() { document.getElementById("uc-is-admin-row").classList.toggle("hidden", !meInfo.is_admin); await refreshUsers(); } let cachedUsers = []; let editingUserId = null; async function refreshUsers() { cachedUsers = await getJson("/admin/users"); const tbody = document.querySelector("#users-table tbody"); fillTable( tbody, cachedUsers.map((u) => { const actions = el("td", {}); actions.appendChild(actionButton("Bearbeiten", "btn-secondary", () => showUserEdit(u))); if (u.is_active) { actions.appendChild( actionButton("Deaktivieren", "btn-danger", async () => { await sendJson(`/admin/users/${u.id}/deactivate`, "POST", {}); showBanner(`Benutzer '${u.username}' deaktiviert.`, "ok"); await refreshUsers(); }) ); } actions.appendChild( actionButton("Loeschen", "btn-danger", async () => { const result = await apiFetch(`/admin/users/${u.id}`, { method: "DELETE" }); showBanner( result.hard_deleted ? `Benutzer '${u.username}' vollstaendig geloescht.` : `Benutzer '${u.username}' hat Audit-Historie und wurde deaktiviert/anonymisiert.`, "ok" ); await refreshUsers(); }) ); return el("tr", {}, [ el("td", { textContent: String(u.id) }), el("td", { textContent: u.username }), el("td", { textContent: u.is_admin ? "ja" : "nein" }), el("td", { textContent: u.is_active ? "ja" : "nein" }), el("td", { textContent: u.totp_enrolled ? "ja" : "nein" }), el("td", { textContent: u.created_at }), actions, ]); }) ); } function showUserEdit(u) { editingUserId = u.id; document.getElementById("user-edit-panel").classList.remove("hidden"); document.getElementById("user-edit-name").textContent = `${u.username} (#${u.id})`; document.getElementById("ue-is-admin-row").classList.toggle("hidden", !meInfo.is_admin); document.getElementById("ue-is-admin").checked = u.is_admin; document.getElementById("ue-is-active").checked = u.is_active; document.getElementById("ue-new-password").value = ""; } document.getElementById("user-edit-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const payload = { is_active: document.getElementById("ue-is-active").checked }; if (meInfo.is_admin) payload.is_admin = document.getElementById("ue-is-admin").checked; const pw = document.getElementById("ue-new-password").value; if (pw) payload.new_password = pw; await sendJson(`/admin/users/${editingUserId}`, "PUT", payload); showBanner("Benutzer aktualisiert.", "ok"); await refreshUsers(); } catch (err) { showBanner(err.message, "error"); } }); document.getElementById("user-create-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const username = document.getElementById("uc-username").value.trim(); const password = document.getElementById("uc-password").value; const isAdmin = meInfo.is_admin && document.getElementById("uc-is-admin").checked; await sendJson("/admin/users", "POST", { username, initial_password: password, is_admin: isAdmin, }); showBanner(`Benutzer '${username}' angelegt.`, "ok"); ev.target.reset(); await refreshUsers(); await refreshSharedSelects(); } catch (err) { showBanner(err.message, "error"); } }); // --------------------------------------------------------------------- // Benutzergruppen // --------------------------------------------------------------------- let cachedGroups = []; let activeGroupId = null; let editingGroupId = null; async function loadGroupsTab() { await refreshGroups(); } async function refreshGroups() { cachedGroups = await getJson("/admin/user-groups"); const tbody = document.querySelector("#groups-table tbody"); fillTable( tbody, cachedGroups.map((g) => el("tr", {}, [ el("td", { textContent: String(g.id) }), el("td", { textContent: g.name }), el("td", { textContent: g.description || "" }), el("td", { textContent: String(g.member_count) }), el("td", {}, [ actionButton("Mitglieder", "btn-secondary", () => showGroupMembers(g)), actionButton("Bearbeiten", "btn-secondary", () => showGroupEdit(g)), actionButton("Loeschen", "btn-danger", async () => { await apiFetch(`/admin/user-groups/${g.id}`, { method: "DELETE" }); showBanner(`Gruppe '${g.name}' geloescht.`, "ok"); await refreshGroups(); }), ]), ]) ) ); } function showGroupEdit(g) { editingGroupId = g.id; document.getElementById("group-edit-panel").classList.remove("hidden"); document.getElementById("group-edit-name").textContent = g.name; document.getElementById("ge-name").value = g.name; document.getElementById("ge-description").value = g.description || ""; } document.getElementById("group-edit-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { await sendJson(`/admin/user-groups/${editingGroupId}`, "PUT", { name: document.getElementById("ge-name").value.trim(), description: document.getElementById("ge-description").value.trim() || null, }); showBanner("Gruppe aktualisiert.", "ok"); await refreshGroups(); } catch (err) { showBanner(err.message, "error"); } }); async function showGroupMembers(group) { activeGroupId = group.id; document.getElementById("group-members-panel").classList.remove("hidden"); document.getElementById("group-members-name").textContent = group.name; await refreshGroupMembers(); const select = document.getElementById("gm-user-select"); select.replaceChildren(...optionsFromList(cachedUsers, "id", (u) => `${u.username} (#${u.id})`)); } async function refreshGroupMembers() { if (activeGroupId === null) return; const members = await getJson(`/admin/user-groups/${activeGroupId}/members`); const tbody = document.querySelector("#group-members-table tbody"); fillTable( tbody, members.map((m) => el("tr", {}, [ el("td", { textContent: String(m.user_id) }), el("td", { textContent: m.username }), el("td", { textContent: m.added_at }), el("td", {}, [ actionButton("Entfernen", "btn-danger", async () => { await apiFetch(`/admin/user-groups/${activeGroupId}/members/${m.user_id}`, { method: "DELETE" }); await refreshGroupMembers(); await refreshGroups(); }), ]), ]) ) ); } document.getElementById("group-create-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const name = document.getElementById("gc-name").value.trim(); const description = document.getElementById("gc-description").value.trim() || null; await sendJson("/admin/user-groups", "POST", { name, description }); showBanner(`Benutzergruppe '${name}' angelegt.`, "ok"); ev.target.reset(); await refreshGroups(); await refreshSharedSelects(); } catch (err) { showBanner(err.message, "error"); } }); document.getElementById("group-member-add-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const userId = Number(document.getElementById("gm-user-select").value); await sendJson(`/admin/user-groups/${activeGroupId}/members`, "POST", { user_id: userId }); showBanner("Mitglied hinzugefuegt.", "ok"); await refreshGroupMembers(); await refreshGroups(); } catch (err) { showBanner(err.message, "error"); } }); // --------------------------------------------------------------------- // Hosts & Verbindungen // --------------------------------------------------------------------- let cachedHostGroups = []; let cachedHosts = []; let cachedSshKeys = []; let cachedRdpCredentials = []; let cachedSshPasswordCredentials = []; let activeHostId = null; let editingHostGroupId = null; async function loadHostsTab() { await Promise.all([refreshHostGroups(), refreshSshKeysCache(), refreshRdpCredentialsCache()]); await refreshHosts(); } async function refreshHostGroups() { cachedHostGroups = await getJson("/admin/host-groups"); const tbody = document.querySelector("#hostgroups-table tbody"); fillTable( tbody, cachedHostGroups.map((g) => el("tr", {}, [ el("td", { textContent: String(g.id) }), el("td", { textContent: g.name }), el("td", { textContent: g.description || "" }), el("td", {}, [ actionButton("Bearbeiten", "btn-secondary", () => showHostGroupEdit(g)), actionButton("Loeschen", "btn-danger", async () => { await apiFetch(`/admin/host-groups/${g.id}`, { method: "DELETE" }); showBanner(`Hostgruppe '${g.name}' geloescht.`, "ok"); await refreshHostGroups(); }), ]), ]) ) ); const hcSelect = document.getElementById("hc-hostgroup"); hcSelect.replaceChildren(...optionsFromList(cachedHostGroups, "id", (g) => `${g.name} (#${g.id})`)); } function showHostGroupEdit(g) { editingHostGroupId = g.id; document.getElementById("hostgroup-edit-panel").classList.remove("hidden"); document.getElementById("hostgroup-edit-name").textContent = g.name; document.getElementById("hge-name").value = g.name; document.getElementById("hge-description").value = g.description || ""; } document.getElementById("hostgroup-edit-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { await sendJson(`/admin/host-groups/${editingHostGroupId}`, "PUT", { name: document.getElementById("hge-name").value.trim(), description: document.getElementById("hge-description").value.trim() || null, }); showBanner("Hostgruppe aktualisiert.", "ok"); await refreshHostGroups(); } catch (err) { showBanner(err.message, "error"); } }); async function refreshHosts() { cachedHosts = await getJson("/admin/hosts"); const groupName = (id) => (cachedHostGroups.find((g) => g.id === id) || {}).name || `#${id}`; const tbody = document.querySelector("#hosts-table tbody"); fillTable( tbody, cachedHosts.map((h) => el("tr", {}, [ el("td", { textContent: String(h.id) }), el("td", { textContent: h.hostname }), el("td", { textContent: h.address }), el("td", { textContent: h.protocol }), el("td", { textContent: h.os_type }), el("td", { textContent: groupName(h.host_group_id) }), el("td", {}, [el("span", { className: `badge ${h.is_active ? "ok" : "danger"}`, textContent: h.is_active ? "aktiv" : "inaktiv" })]), el("td", {}, [ actionButton("Details", "btn-secondary", () => showHostDetail(h.id)), actionButton(h.is_active ? "Loeschen" : "Reaktivieren", h.is_active ? "btn-danger" : "btn-secondary", async () => { if (h.is_active) { await apiFetch(`/admin/hosts/${h.id}`, { method: "DELETE" }); showBanner(`Host '${h.hostname}' deaktiviert/geloescht.`, "ok"); } else { await sendJson(`/admin/hosts/${h.id}`, "PUT", { is_active: true }); showBanner(`Host '${h.hostname}' reaktiviert.`, "ok"); } await refreshHosts(); if (activeHostId === h.id) document.getElementById("host-detail-panel").classList.add("hidden"); }), ]), ]) ) ); } async function refreshSshKeysCache() { cachedSshKeys = await getJson("/admin/ssh-keys"); } async function refreshRdpCredentialsCache() { cachedRdpCredentials = await getJson("/admin/rdp-credentials"); } async function refreshSshPasswordCredentialsCache() { cachedSshPasswordCredentials = await getJson("/admin/ssh-password-credentials"); } function setDetailBanner(message, type) { const box = document.getElementById("host-detail-banner"); if (!message) { box.replaceChildren(); return; } box.replaceChildren(el("div", { className: `banner ${type === "error" ? "error" : "ok"}`, textContent: message })); } function describeEffectiveUsername(host, isRdp) { if (isRdp) { if (host.rdp_credentials_username) { const domain = host.rdp_credentials_domain ? `, Domaene ${host.rdp_credentials_domain}` : ""; return `Anmeldung als "${host.rdp_credentials_username}"${domain} (aus den RDP-Zugangsdaten unten).`; } if (host.rdp_username) { return `Noch kein Zugangsdaten-Objekt zugewiesen -- es wird der alte Wert am Host verwendet ("${host.rdp_username}"). Bitte unten RDP-Zugangsdaten zuweisen.`; } return "Kein Windows-Benutzername hinterlegt -- unten RDP-Zugangsdaten zuweisen (oder im Reiter \"Zugangsdaten\" zuerst anlegen), sonst schlaegt die Anmeldung fehl."; } const named = (host.ssh_keys || []).filter((k) => k.username); if (named.length) { return `Anmeldung als ${named.map((k) => `"${k.username}" (Key ${k.label})`).join(", ")}.`; } if ((host.ssh_keys || []).length === 0 && host.ssh_password_credentials_set) { return `Anmeldung als "${host.ssh_password_credentials_username}" (SSH-Passwort, kein Schluessel zugeordnet).`; } if (host.ssh_username) { return `Noch kein Benutzername am SSH-Key -- es wird der alte Wert am Host verwendet ("${host.ssh_username}"). Bitte im Reiter "Zugangsdaten" am Schluessel eintragen.`; } return 'Kein SSH-Benutzername hinterlegt -- im Reiter "Zugangsdaten" am zugeordneten Schluessel oder als SSH-Passwort eintragen, sonst kommt keine Verbindung zustande.'; } async function showHostDetail(hostId) { setDetailBanner("", "ok"); try { const host = await getJson(`/admin/hosts/${hostId}`); activeHostId = host.id; document.getElementById("host-detail-panel").classList.remove("hidden"); document.getElementById("host-detail-name").textContent = `${host.hostname} (#${host.id})`; document.getElementById("he-hostname").value = host.hostname; document.getElementById("he-address").value = host.address; document.getElementById("he-port").value = String(host.port); document.getElementById("he-clipboard").checked = host.clipboard_enabled; document.getElementById("he-filetransfer").checked = host.file_transfer_enabled; document.getElementById("he-nla").checked = host.rdp_require_nla; document.getElementById("he-ignorecert").checked = host.rdp_ignore_cert; document.getElementById("he-active").checked = host.is_active; const isRdp = host.protocol === "rdp"; // Der Anmeldename steht bei den Zugangsdaten (SSH-Key bzw. // RDP-Passwortsatz). Hier wird nur angezeigt, welcher Name greifen // wuerde -- inklusive des Altbestands am Host, solange er noch benutzt // wird. document.getElementById("he-username-hint").textContent = describeEffectiveUsername(host, isRdp); document.getElementById("he-nla-box").classList.toggle("hidden", !isRdp); document.getElementById("he-ignorecert-box").classList.toggle("hidden", !isRdp); document.getElementById("host-detail-ssh").classList.toggle("hidden", isRdp); document.getElementById("host-detail-rdp").classList.toggle("hidden", !isRdp); document.getElementById("host-key-result").classList.add("hidden"); if (host.ssh_host_key_fingerprint) { const box = document.getElementById("host-key-result"); box.textContent = `Aktueller Fingerprint: ${host.ssh_host_key_fingerprint}`; box.classList.remove("hidden"); } const keysTbody = document.querySelector("#host-ssh-keys-table tbody"); fillTable( keysTbody, host.ssh_keys.map((k) => el("tr", {}, [ el("td", { textContent: String(k.id) }), el("td", { textContent: k.label }), el("td", { textContent: k.username || "(kein Benutzername)" }), el("td", {}, [ actionButton("Entfernen", "btn-danger", async () => { await apiFetch(`/admin/hosts/${host.id}/ssh-keys/${k.id}`, { method: "DELETE" }); await showHostDetail(host.id); }), ]), ]) ) ); const keySelect = document.getElementById("hkm-key-select"); keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`)); document.getElementById("ssh-pw-cred-status").textContent = host.ssh_password_credentials_set ? `SSH-Passwort gesetzt fuer "${host.ssh_password_credentials_username}" (zuletzt aktualisiert: ${host.ssh_password_credentials_updated_at}).` : "Noch kein SSH-Passwort gesetzt."; document.getElementById("hspw-username").value = host.ssh_password_credentials_username || ""; document.getElementById("rdp-cred-status").textContent = host.rdp_credentials_set ? `Zugewiesen: "${host.rdp_credentials_label}" -- Anmeldung als "${host.rdp_credentials_username || "(kein Benutzername)"}" (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).` : "Noch keine RDP-Zugangsdaten zugewiesen."; const rdpSelect = document.getElementById("hrcm-select"); rdpSelect.replaceChildren(...optionsFromList(cachedRdpCredentials, "id", (c) => `${c.label} (${c.username}${c.domain ? "@" + c.domain : ""}, #${c.id})`)); if (host.rdp_credentials_id) rdpSelect.value = String(host.rdp_credentials_id); } catch (err) { setDetailBanner(err.message, "error"); } } document.getElementById("host-edit-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { await sendJson(`/admin/hosts/${activeHostId}`, "PUT", { hostname: document.getElementById("he-hostname").value.trim(), address: document.getElementById("he-address").value.trim(), port: Number(document.getElementById("he-port").value), clipboard_enabled: document.getElementById("he-clipboard").checked, file_transfer_enabled: document.getElementById("he-filetransfer").checked, rdp_require_nla: document.getElementById("he-nla").checked, rdp_ignore_cert: document.getElementById("he-ignorecert").checked, is_active: document.getElementById("he-active").checked, }); showBanner("Host aktualisiert.", "ok"); await refreshHosts(); await showHostDetail(activeHostId); } catch (err) { setDetailBanner(err.message, "error"); } }); document.getElementById("hc-protocol").addEventListener("change", (ev) => { const isRdp = ev.target.value === "rdp"; document.getElementById("hc-nla-box").classList.toggle("hidden", !isRdp); document.getElementById("hc-ignorecert-box").classList.toggle("hidden", !isRdp); document.getElementById("hc-ignorecert-hint").classList.toggle("hidden", !isRdp); document.getElementById("hc-port").value = isRdp ? "3389" : "22"; document.getElementById("hc-os-type").value = isRdp ? "windows" : "linux"; }); document.getElementById("hostgroup-create-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const name = document.getElementById("hgc-name").value.trim(); const description = document.getElementById("hgc-description").value.trim() || null; await sendJson("/admin/host-groups", "POST", { name, description }); showBanner(`Hostgruppe '${name}' angelegt.`, "ok"); ev.target.reset(); await refreshHostGroups(); await refreshSharedSelects(); } catch (err) { showBanner(err.message, "error"); } }); document.getElementById("host-create-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const protocol = document.getElementById("hc-protocol").value; const payload = { host_group_id: Number(document.getElementById("hc-hostgroup").value), hostname: document.getElementById("hc-hostname").value.trim(), address: document.getElementById("hc-address").value.trim(), protocol, port: Number(document.getElementById("hc-port").value), os_type: document.getElementById("hc-os-type").value, rdp_require_nla: document.getElementById("hc-nla").checked, rdp_ignore_cert: document.getElementById("hc-ignorecert").checked, clipboard_enabled: document.getElementById("hc-clipboard").checked, file_transfer_enabled: document.getElementById("hc-filetransfer").checked, }; await sendJson("/admin/hosts", "POST", payload); showBanner(`Verbindung '${payload.hostname}' angelegt.`, "ok"); ev.target.reset(); document.getElementById("hc-protocol").dispatchEvent(new Event("change")); await refreshHosts(); await refreshSharedSelects(); } catch (err) { showBanner(err.message, "error"); } }); document.getElementById("host-discover-key-btn").addEventListener("click", async () => { try { const result = await sendJson(`/admin/hosts/${activeHostId}/discover-host-key`, "POST", {}); const box = document.getElementById("host-key-result"); box.textContent = `Fingerprint: ${result.fingerprint}`; box.classList.remove("hidden"); showBanner("Host-Key ermittelt und gespeichert.", "ok"); } catch (err) { setDetailBanner(err.message, "error"); } }); document.getElementById("host-key-map-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const keyId = document.getElementById("hkm-key-select").value; await sendJson(`/admin/hosts/${activeHostId}/ssh-keys/${keyId}`, "POST", {}); showBanner("SSH-Key zugeordnet.", "ok"); await showHostDetail(activeHostId); } catch (err) { setDetailBanner(err.message, "error"); } }); document.getElementById("host-rdp-cred-map-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const credentialId = document.getElementById("hrcm-select").value; if (!credentialId) { showBanner('Bitte zuerst im Reiter "Zugangsdaten" ein RDP-Zugangsdaten-Objekt anlegen.', "error"); return; } await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials/${credentialId}`, "POST", {}); showBanner("RDP-Zugangsdaten zugewiesen.", "ok"); await showHostDetail(activeHostId); } catch (err) { setDetailBanner(err.message, "error"); } }); document.getElementById("hrc-unassign-btn").addEventListener("click", async () => { try { await apiFetch(`/admin/hosts/${activeHostId}/rdp-credentials`, { method: "DELETE" }); showBanner("Zuordnung entfernt.", "ok"); await showHostDetail(activeHostId); } catch (err) { setDetailBanner(err.message, "error"); } }); document.getElementById("host-ssh-pw-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const username = document.getElementById("hspw-username").value.trim(); const password = document.getElementById("hspw-password").value; await sendJson(`/admin/hosts/${activeHostId}/ssh-password`, "PUT", { username, password }); showBanner("SSH-Passwort gespeichert.", "ok"); document.getElementById("hspw-password").value = ""; await showHostDetail(activeHostId); } catch (err) { setDetailBanner(err.message, "error"); } }); document.getElementById("hspw-delete-btn").addEventListener("click", async () => { try { await apiFetch(`/admin/hosts/${activeHostId}/ssh-password`, { method: "DELETE" }); showBanner("SSH-Passwort geloescht.", "ok"); await showHostDetail(activeHostId); } catch (err) { setDetailBanner(err.message, "error"); } }); // --------------------------------------------------------------------- // Zugangsdaten (SSH-Keys + RDP-Passwoerter) // --------------------------------------------------------------------- let editingSshKeyId = null; async function loadCredentialsTab() { await Promise.all([refreshSshKeys(), refreshRdpCredentials()]); } async function refreshSshKeys() { cachedSshKeys = await getJson("/admin/ssh-keys"); const tbody = document.querySelector("#ssh-keys-table tbody"); fillTable( tbody, cachedSshKeys.map((k) => el("tr", {}, [ el("td", { textContent: String(k.id) }), el("td", { textContent: k.label }), el("td", { textContent: k.username || "-" }), el("td", { textContent: k.key_type }), el("td", { textContent: k.has_passphrase ? "hinterlegt" : "-" }), el("td", { textContent: k.created_at }), el("td", { textContent: k.rotated_at || "-" }), el("td", {}, [ actionButton("Bearbeiten", "btn-secondary", () => showSshKeyEdit(k)), actionButton("Loeschen", "btn-danger", async () => { const result = await apiFetch(`/admin/ssh-keys/${k.id}`, { method: "DELETE" }); const suffix = result.unmapped_host_ids.length ? ` (Zuordnung zu Host(s) ${result.unmapped_host_ids.join(", ")} entfernt)` : ""; showBanner(`SSH-Key '${k.label}' geloescht.${suffix}`, "ok"); await refreshSshKeys(); }), ]), ]) ) ); } function showSshKeyEdit(k) { editingSshKeyId = k.id; document.getElementById("ssh-key-edit-panel").classList.remove("hidden"); document.getElementById("ssh-key-edit-name").textContent = `${k.label} (#${k.id})`; document.getElementById("ske-label").value = k.label; document.getElementById("ske-username").value = k.username || ""; document.getElementById("skr-type").value = k.key_type; document.getElementById("skp-passphrase").value = ""; document.getElementById("ske-passphrase-state").textContent = k.has_passphrase ? "Fuer diesen Schluessel ist eine Passphrase hinterlegt." : "Fuer diesen Schluessel ist KEINE Passphrase hinterlegt. Ist der Schluessel verschluesselt, schlaegt jeder Verbindungsversuch fehl."; } async function saveSshKeyPassphrase(value) { // Wird gegen das bereits gespeicherte Schluesselmaterial geprueft // (update_ssh_key in app/admin/routes.py); passt sie nicht, antwortet der // Server mit 400 und es wird nichts gespeichert. await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", { passphrase: value }); await refreshSshKeys(); const updated = cachedSshKeys.find((k) => k.id === editingSshKeyId); if (updated) showSshKeyEdit(updated); } document.getElementById("ssh-key-passphrase-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const value = document.getElementById("skp-passphrase").value; if (!value) { showBanner("Bitte eine Passphrase eingeben oder 'Passphrase entfernen' verwenden.", "error"); return; } await saveSshKeyPassphrase(value); showBanner("Passphrase gespeichert und gegen den Schluessel geprueft.", "ok"); } catch (err) { showBanner(err.message, "error"); } }); document.getElementById("skp-clear-btn").addEventListener("click", async () => { try { await saveSshKeyPassphrase(null); showBanner("Passphrase entfernt.", "ok"); } catch (err) { showBanner(err.message, "error"); } }); document.getElementById("ssh-key-edit-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", { label: document.getElementById("ske-label").value.trim() || null, // Leeres Feld -> null entfernt den Benutzernamen bewusst (die // Server-Semantik unterscheidet zwischen "Feld fehlt" und "null"). username: document.getElementById("ske-username").value.trim() || null, }); showBanner("SSH-Key aktualisiert.", "ok"); await refreshSshKeys(); } catch (err) { showBanner(err.message, "error"); } }); document.getElementById("ssh-key-rotate-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", { key_type: document.getElementById("skr-type").value, private_key_pem: document.getElementById("skr-private").value, public_key: document.getElementById("skr-public").value, passphrase: document.getElementById("skr-passphrase").value || null, }); showBanner("Neues Schluesselmaterial gespeichert.", "ok"); ev.target.reset(); await refreshSshKeys(); } catch (err) { showBanner(err.message, "error"); } }); document.getElementById("skc-generate-btn").addEventListener("click", async () => { try { const keyType = document.getElementById("skc-type").value; if (keyType === "ca-cert") { showBanner("Automatische Erzeugung ist fuer ca-cert nicht moeglich -- bitte Schluessel/Zertifikat der eigenen CA einfuegen.", "error"); return; } const result = await sendJson("/admin/ssh-keys/generate", "POST", { key_type: keyType }); document.getElementById("skc-private").value = result.private_key_pem; document.getElementById("skc-public").value = result.public_key; showBanner("Schluesselpaar erzeugt -- bitte Label (und ggf. Benutzername) ausfuellen und 'Anlegen' druecken, um es zu speichern.", "ok"); } catch (err) { showBanner(err.message, "error"); } }); document.getElementById("ssh-key-create-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const payload = { label: document.getElementById("skc-label").value.trim(), username: document.getElementById("skc-username").value.trim() || null, key_type: document.getElementById("skc-type").value, private_key_pem: document.getElementById("skc-private").value, public_key: document.getElementById("skc-public").value, // Leeres Feld -> null: der Schluessel gilt dann als unverschluesselt. passphrase: document.getElementById("skc-passphrase").value || null, }; await sendJson("/admin/ssh-keys", "POST", payload); showBanner(`SSH-Key '${payload.label}' angelegt.`, "ok"); ev.target.reset(); await refreshSshKeys(); } catch (err) { showBanner(err.message, "error"); } }); let editingRdpCredentialId = null; async function refreshRdpCredentials() { cachedRdpCredentials = await getJson("/admin/rdp-credentials"); const tbody = document.querySelector("#rdp-creds-table tbody"); fillTable( tbody, cachedRdpCredentials.map((r) => el("tr", {}, [ el("td", { textContent: String(r.id) }), el("td", { textContent: r.label }), el("td", { textContent: r.username || "-" }), el("td", { textContent: r.domain || "-" }), el("td", { textContent: r.assigned_hosts.length ? r.assigned_hosts.map((h) => h.hostname).join(", ") : "(keinem Server zugewiesen)", }), el("td", { textContent: r.rotated_at || r.created_at }), el("td", {}, [ actionButton("Bearbeiten", "btn-secondary", () => showRdpCredentialEdit(r)), actionButton("Loeschen", "btn-danger", async () => { const result = await apiFetch(`/admin/rdp-credentials/${r.id}`, { method: "DELETE" }); const suffix = result.unmapped_host_ids.length ? ` (Zuordnung zu Host(s) ${result.unmapped_host_ids.join(", ")} entfernt)` : ""; showBanner(`RDP-Zugangsdaten '${r.label}' geloescht.${suffix}`, "ok"); await refreshRdpCredentials(); }), ]), ]) ) ); } function showRdpCredentialEdit(r) { editingRdpCredentialId = r.id; document.getElementById("rdp-cred-edit-panel").classList.remove("hidden"); document.getElementById("rdp-cred-edit-name").textContent = `${r.label} (#${r.id})`; document.getElementById("rce-label").value = r.label; document.getElementById("rce-username").value = r.username || ""; document.getElementById("rce-domain").value = r.domain || ""; document.getElementById("rcr-password").value = ""; } document.getElementById("rdp-cred-edit-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { await sendJson(`/admin/rdp-credentials/${editingRdpCredentialId}`, "PUT", { label: document.getElementById("rce-label").value.trim() || null, username: document.getElementById("rce-username").value.trim() || null, // Leeres Feld -> null entfernt die Domaene bewusst (Feld-vorhanden- // vs-null-Semantik wie bei SSH-Keys). domain: document.getElementById("rce-domain").value.trim() || null, }); showBanner("RDP-Zugangsdaten aktualisiert.", "ok"); await refreshRdpCredentials(); } catch (err) { showBanner(err.message, "error"); } }); document.getElementById("rdp-cred-rotate-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const password = document.getElementById("rcr-password").value; await sendJson(`/admin/rdp-credentials/${editingRdpCredentialId}`, "PUT", { password }); showBanner("Neues Passwort gespeichert.", "ok"); document.getElementById("rcr-password").value = ""; await refreshRdpCredentials(); } catch (err) { showBanner(err.message, "error"); } }); document.getElementById("rdp-cred-create-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const payload = { label: document.getElementById("rcc-label").value.trim(), username: document.getElementById("rcc-username").value.trim(), domain: document.getElementById("rcc-domain").value.trim() || null, password: document.getElementById("rcc-password").value, }; await sendJson("/admin/rdp-credentials", "POST", payload); showBanner(`RDP-Zugangsdaten '${payload.label}' angelegt.`, "ok"); ev.target.reset(); await refreshRdpCredentials(); } catch (err) { showBanner(err.message, "error"); } }); // --------------------------------------------------------------------- // Rollen // --------------------------------------------------------------------- async function loadRolesTab() { if (ROLE_NAMES.length === 0) { ROLE_NAMES = await getJson("/admin/roles/names"); } roleCheckboxGrid(document.getElementById("grg-role-grid")); await refreshSharedSelects(); await Promise.all([refreshSshKeysCache(), refreshRdpCredentialsCache(), refreshSshPasswordCredentialsCache()]); populateCredentialSelect(); await Promise.all([refreshRoleGrants(), refreshGroupRoleGrants(), refreshGroupCredentialGrants()]); } async function refreshRoleGrants() { // Teil D Schritt 5: abgeleitete Sicht (GET /admin/roles liefert seither // effektive Rechte statt einer Vergabe-Tabelle) -- nicht editierbar, // daher keine Aktionen-Spalte mehr. Aendern laeuft ausschliesslich ueber // die Gruppenvergabe (refreshGroupRoleGrants() unten). const grants = await getJson("/admin/roles"); const tbody = document.querySelector("#role-grants-table tbody"); fillTable( tbody, grants.map((g) => el("tr", {}, [ el("td", { textContent: g.username }), el("td", { textContent: g.host_group_name }), el("td", { textContent: g.role_name }), el("td", { textContent: g.via_group_name }), el("td", { textContent: g.expires_at || "-" }), ]) ) ); } async function refreshGroupRoleGrants() { const grants = await getJson("/admin/group-roles"); const tbody = document.querySelector("#group-role-grants-table tbody"); fillTable( tbody, grants.map((g) => el("tr", {}, [ el("td", { textContent: g.user_group_name }), el("td", { textContent: g.host_group_name }), el("td", { textContent: g.role_name }), el("td", { textContent: g.expires_at || "-" }), el("td", {}, [ actionButton("Entziehen", "btn-danger", async () => { await sendJson("/admin/group-roles/revoke", "POST", { user_group_id: g.user_group_id, host_group_id: g.host_group_id, role_name: g.role_name, }); await refreshGroupRoleGrants(); }), ]), ]) ) ); } document.getElementById("group-role-grant-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const role_names = checkedValues(document.getElementById("grg-role-grid")); if (role_names.length === 0) throw new Error("Mindestens eine Rolle auswaehlen."); await sendJson("/admin/group-roles/grant", "POST", { user_group_id: Number(document.getElementById("grg-group").value), host_group_id: Number(document.getElementById("grg-hostgroup").value), role_names, expires_at: document.getElementById("grg-expires").value.trim() || null, }); showBanner(`Rolle(n) an Gruppe vergeben: ${role_names.join(", ")}.`, "ok"); document.querySelectorAll("#grg-role-grid input:checked").forEach((c) => { c.checked = false; }); await refreshGroupRoleGrants(); } catch (err) { showBanner(err.message, "error"); } }); // --------------------------------------------------------------------- // Zugangsdaten-Freigabe an Benutzergruppen (Achse B, Teil D.6 Schritt 5) // --------------------------------------------------------------------- const CREDENTIAL_KIND_LABELS = { ssh_key: "SSH-Schluessel", rdp_credential: "RDP-Zugangsdaten", ssh_password_credential: "SSH-Passwort", }; function credentialListForKind(kind) { if (kind === "ssh_key") return cachedSshKeys; if (kind === "rdp_credential") return cachedRdpCredentials; return cachedSshPasswordCredentials; } function credentialLabel(kind, credentialId) { const item = credentialListForKind(kind).find((c) => c.id === credentialId); return item ? `${item.label} (#${item.id})` : `#${credentialId}`; } function populateCredentialSelect() { const kind = document.getElementById("gcg-kind").value; const select = document.getElementById("gcg-credential"); select.replaceChildren(...optionsFromList(credentialListForKind(kind), "id", (c) => `${c.label} (#${c.id})`)); } document.getElementById("gcg-kind").addEventListener("change", populateCredentialSelect); async function refreshGroupCredentialGrants() { const kinds = ["ssh_key", "rdp_credential", "ssh_password_credential"]; const perKind = await Promise.all(kinds.map((kind) => getJson(`/admin/group-credentials/${kind}`))); const rows = []; kinds.forEach((kind, idx) => { for (const g of perKind[idx]) rows.push({ kind, ...g }); }); rows.sort((a, b) => a.user_group_name.localeCompare(b.user_group_name) || a.kind.localeCompare(b.kind)); const tbody = document.querySelector("#group-credential-grants-table tbody"); fillTable( tbody, rows.map((g) => el("tr", {}, [ el("td", { textContent: g.user_group_name }), el("td", { textContent: CREDENTIAL_KIND_LABELS[g.kind] || g.kind }), el("td", { textContent: credentialLabel(g.kind, g.credential_id) }), el("td", { textContent: g.expires_at || "-" }), el("td", {}, [ actionButton("Entziehen", "btn-danger", async () => { await sendJson(`/admin/group-credentials/${g.kind}/revoke`, "POST", { user_group_id: g.user_group_id, credential_id: g.credential_id, }); await refreshGroupCredentialGrants(); }), ]), ]) ) ); } document.getElementById("group-credential-grant-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const kind = document.getElementById("gcg-kind").value; const credentialId = Number(document.getElementById("gcg-credential").value); if (!credentialId) throw new Error("Zugangsdatensatz auswaehlen."); await sendJson(`/admin/group-credentials/${kind}/grant`, "POST", { user_group_id: Number(document.getElementById("gcg-group").value), credential_id: credentialId, expires_at: document.getElementById("gcg-expires").value.trim() || null, }); showBanner(`Zugangsdatensatz freigegeben: ${CREDENTIAL_KIND_LABELS[kind]}.`, "ok"); document.getElementById("gcg-expires").value = ""; await refreshGroupCredentialGrants(); } catch (err) { showBanner(err.message, "error"); } }); // --------------------------------------------------------------------- // API-Tokens // --------------------------------------------------------------------- let cachedScopes = []; async function loadTokensTab() { if (cachedScopes.length === 0) { const result = await getJson("/admin/scopes"); cachedScopes = result.scopes; const grid = document.getElementById("tc-scope-grid"); grid.replaceChildren( ...cachedScopes.map((scope) => { const id = `scope-${scope.replace(":", "-")}`; const row = el("div", { className: "checkbox-row" }); const checkbox = el("input", { type: "checkbox", id, value: scope }); const label = el("label", { for: id, textContent: scope }); row.appendChild(checkbox); row.appendChild(label); return row; }) ); } await refreshSharedSelects(); await refreshTokens(); } async function refreshTokens() { const tokens = await getJson("/admin/tokens"); const tbody = document.querySelector("#tokens-table tbody"); fillTable( tbody, tokens.map((t) => { const status = t.revoked_at ? "widerrufen" : (t.expires_at && t.expires_at < new Date().toISOString() ? "abgelaufen" : "aktiv"); const scopesCell = el("td", {}); for (const s of t.scopes) { scopesCell.appendChild(el("span", { className: "badge", textContent: s })); } const actionsCell = el("td", {}); if (!t.revoked_at) { actionsCell.appendChild( actionButton("Widerrufen", "btn-danger", async () => { await sendJson(`/admin/tokens/${t.id}/revoke`, "POST", {}); await refreshTokens(); }) ); } return el("tr", {}, [ el("td", { textContent: String(t.id) }), el("td", { textContent: t.label }), el("td", { textContent: t.username }), el("td", { textContent: t.prefix }), scopesCell, el("td", { textContent: t.last_used_at || "nie" }), el("td", { textContent: status }), actionsCell, ]); }) ); } document.getElementById("token-create-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { const scopes = Array.from(document.querySelectorAll("#tc-scope-grid input:checked")).map((c) => c.value); if (scopes.length === 0) { throw new Error("Mindestens einen Scope auswaehlen."); } const payload = { label: document.getElementById("tc-label").value.trim(), user_id: Number(document.getElementById("tc-user").value), scopes, expires_at: document.getElementById("tc-expires").value.trim() || null, }; const result = await sendJson("/admin/tokens", "POST", payload); const box = document.getElementById("token-reveal-box"); box.textContent = `Token (nur jetzt sichtbar, bitte sicher speichern): ${result.token}`; box.classList.remove("hidden"); showBanner(`Token '${payload.label}' erstellt.`, "ok"); ev.target.reset(); document.querySelectorAll("#tc-scope-grid input:checked").forEach((c) => { c.checked = false; }); await refreshTokens(); } catch (err) { showBanner(err.message, "error"); } }); // --------------------------------------------------------------------- // Sessions (nur Super-Admin) -- aktive + historische Sitzungen, Beenden, // Link zur Aufzeichnung. // --------------------------------------------------------------------- let sessionsActiveOnly = true; let sessionsAutoTimer = null; async function loadSessionsTab() { document.getElementById("sessions-active-only").checked = sessionsActiveOnly; await refreshSessions(); if (sessionsAutoTimer === null) { sessionsAutoTimer = window.setInterval(() => { if (currentTab === "sessions") refreshSessions().catch(() => {}); }, 5000); } } document.getElementById("sessions-active-only").addEventListener("change", (ev) => { sessionsActiveOnly = ev.target.checked; refreshSessions().catch((err) => showBanner(err.message, "error")); }); async function refreshSessions() { const rows = await getJson(`/admin/sessions?active_only=${sessionsActiveOnly}&limit=300`); const tbody = document.querySelector("#sessions-table tbody"); fillTable( tbody, rows.map((s) => { const actions = el("td", {}); if (s.is_active && s.killable) { actions.appendChild( actionButton("Beenden", "btn-danger", async () => { await sendJson(`/admin/sessions/${s.id}/terminate`, "POST", {}); showBanner(`Sitzung #${s.id} beendet.`, "ok"); await refreshSessions(); }) ); } else if (s.is_active) { actions.appendChild(el("span", { className: "hint", textContent: "anderer Prozess" })); } if (s.watchable) { actions.appendChild( actionButton("Live ansehen", "btn-secondary", () => watchSession(s)) ); } if (s.has_recording) { actions.appendChild( actionButton("Wiedergabe", "btn-secondary", () => showSessionRecording(s)) ); } return el("tr", {}, [ el("td", { textContent: String(s.id) }), el("td", { textContent: s.username }), el("td", { textContent: `${s.hostname} (${s.host_group_name})` }), el("td", { textContent: s.protocol.toUpperCase() }), el("td", { textContent: s.started_at }), el("td", {}, [ el("span", { className: `badge ${s.is_active ? "ok" : ""}`, textContent: s.is_active ? "aktiv" : (s.end_reason || "beendet"), }), ]), el("td", { textContent: s.client_ip }), actions, ]); }) ); } // --- Live-Mitschau einer aktiven SSH-Sitzung (nur lesend) -------------- // // Verbindet sich mit GET /ws/sessions/{id}/watch (app/ssh_proxy/ // terminal_ws.py) und schreibt jedes "output"-Frame in ein eigenes, // schreibgeschuetztes xterm.js-Terminal -- exakt dieselben Frames, die // auch der Sitzungsinhaber selbst bekommt (active_sessions.broadcast()). // Es gibt hier bewusst KEINEN Eingabepfad: ein Beobachter kann die // fremde Sitzung nicht beeinflussen. let activeWatch = null; function closeWatchOverlay() { document.getElementById("watch-overlay").classList.add("hidden"); if (activeWatch) { activeWatch.destroy(); activeWatch = null; } document.getElementById("watch-stage").replaceChildren(); } document.getElementById("watch-close-btn").addEventListener("click", closeWatchOverlay); // Klick auf den abgedunkelten Hintergrund (nicht auf das Panel selbst) // schliesst die Live-Ansicht ebenfalls. document.getElementById("watch-overlay").addEventListener("click", (ev) => { if (ev.target === ev.currentTarget) closeWatchOverlay(); }); function watchSession(session) { closeWatchOverlay(); const overlay = document.getElementById("watch-overlay"); const title = document.getElementById("watch-title"); const statusEl = document.getElementById("watch-status"); const stage = document.getElementById("watch-stage"); overlay.classList.remove("hidden"); title.textContent = `Live-Ansicht -- Sitzung #${session.id} (${session.username} @ ${session.hostname})`; statusEl.textContent = "verbinde ..."; const container = el("div", { className: "rec-terminal" }); stage.appendChild(container); const term = new Terminal({ cursorBlink: false, disableStdin: true, convertEol: false, fontFamily: "Menlo, Consolas, monospace", fontSize: 13, theme: { background: "#000000" }, }); const fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); // Bug-Fix (Live-Test, F.6/Aufgabe#11, siehe terminal.js _setupTerminal() // fuer die ausfuehrliche Begruendung): DOM-Renderer-Faerbung wird durch // die strikte CSP (style-src 'self') stillschweigend verworfen. Canvas- // Renderer zeichnet stattdessen direkt auf und ist CSP-unabhaengig. try { term.loadAddon(new CanvasAddon.CanvasAddon()); } catch (err) { console.warn("CanvasAddon konnte nicht geladen werden, Fallback auf DOM-Renderer:", err); } term.open(container); fitAddon.fit(); const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(`${proto}//${window.location.host}/ws/sessions/${session.id}/watch`); ws.addEventListener("open", () => { statusEl.textContent = "verbunden (nur lesend)"; }); ws.addEventListener("close", (ev) => { statusEl.textContent = ev.reason ? `getrennt: ${ev.reason}` : "getrennt (Sitzung vermutlich beendet)"; }); ws.addEventListener("error", () => { statusEl.textContent = "Verbindungsfehler"; }); ws.addEventListener("message", (ev) => { const msg = JSON.parse(ev.data); if (msg.type === "output") { term.write(recB64decode(msg.data)); } }); activeWatch = { destroy() { try { ws.close(); } catch (_) { /* bereits getrennt */ } term.dispose(); }, }; } // --- Wiedergabe (SSH: Terminal-Replay ueber xterm.js; RDP: volle // grafische Wiedergabe ueber Guacamole.SessionRecording) -------------- // // Die vom Recorder aufgezeichneten "output"-Instruktionen einer RDP- // Sitzung sind bereits ein gueltiger .guac-Datenstrom (siehe // app/recordings/recorder.py + app/rdp_proxy/guacd_client.py:: // encode_instruction): guacd sendet als Teil des normalen Protokolls // periodische "sync"-Instruktionen mit Zeitstempel, genau das nutzt // Guacamole.SessionRecording zur Framebildung. Aneinandergehaengt ergeben // die "output"-Eintraege deshalb exakt das Format, das die Bibliothek // erwartet -- kein eigener Player noetig. // Bugfix ("SSH-Wiedergabe funktioniert nicht"): NICHT als JS-String ueber // decodeURIComponent(escape(atob(...))) dekodieren. Jeder aufgezeichnete // "output"-Eintrag entspricht genau einem process.stdout.read()-Chunk auf // dem Server (app/ssh_proxy/terminal_ws.py) -- ein Mehrbyte-UTF-8-Zeichen // (Umlaute/Sonderzeichen in Banner, Prompt, htop, ls --color etc.) kann // dabei GENAU an der Chunk-Grenze zerschnitten sein. decodeURIComponent() // wirft dann "URI malformed" fuer diesen Eintrag -- und weil term.write() // in scheduleNext()/seekTo() innerhalb eines try-losen setTimeout-Callbacks // steht, bricht genau dort die gesamte Wiedergabe-Kette endgueltig ab // (idx wird nie mehr erhoeht, scheduleNext() nie mehr aufgerufen): die // Wiedergabe blieb an der ersten betroffenen Stelle fuer immer haengen, // oft schon beim Login-Banner. term.write() nimmt daher direkt die rohen // Bytes als Uint8Array entgegen -- xterm.js ist ein echter // Terminal-Emulator mit eigenem, chunk-uebergreifend korrektem // UTF-8-Parser, genau fuer diesen Fall gebaut (siehe auch // static/js/terminal.js::b64decodeBytes fuer dieselbe Klasse Bug in der // Live-Ansicht). function recB64decode(b64) { const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes; } function formatMs(ms) { const totalSec = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(totalSec / 60); const s = totalSec % 60; return `${m}:${String(s).padStart(2, "0")}`; } let activePlayer = null; function closeRecordingOverlay() { document.getElementById("rec-overlay").classList.add("hidden"); if (activePlayer) { activePlayer.destroy(); activePlayer = null; } document.getElementById("rec-stage").replaceChildren(); } document.getElementById("rec-close-btn").addEventListener("click", closeRecordingOverlay); // Klick auf den abgedunkelten Hintergrund (nicht auf das Panel selbst) // schliesst die Wiedergabe ebenfalls. document.getElementById("rec-overlay").addEventListener("click", (ev) => { if (ev.target === ev.currentTarget) closeRecordingOverlay(); }); function createSshPlayer(entries) { const outputEntries = entries.filter((e) => e.dir === "output"); const stage = document.getElementById("rec-stage"); const container = el("div", { className: "rec-terminal" }); stage.appendChild(container); const term = new Terminal({ cursorBlink: false, disableStdin: true, convertEol: false, fontFamily: "Menlo, Consolas, monospace", fontSize: 13, theme: { background: "#000000" }, }); const fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); // Bug-Fix (Live-Test, F.6/Aufgabe#11, siehe terminal.js _setupTerminal() // fuer die ausfuehrliche Begruendung): DOM-Renderer-Faerbung wird durch // die strikte CSP (style-src 'self') stillschweigend verworfen. Canvas- // Renderer zeichnet stattdessen direkt auf und ist CSP-unabhaengig. try { term.loadAddon(new CanvasAddon.CanvasAddon()); } catch (err) { console.warn("CanvasAddon konnte nicht geladen werden, Fallback auf DOM-Renderer:", err); } term.open(container); fitAddon.fit(); const playBtn = document.getElementById("rec-playpause-btn"); const seek = document.getElementById("rec-seek"); const timeLabel = document.getElementById("rec-time"); const speedSelect = document.getElementById("rec-speed"); speedSelect.classList.remove("hidden"); const durationMs = outputEntries.length ? outputEntries[outputEntries.length - 1].t * 1000 : 0; seek.min = "0"; seek.max = String(Math.round(durationMs)); seek.value = "0"; let idx = 0; let playing = false; let timer = null; let speed = Number(speedSelect.value) || 1; let realStart = 0; let baseMs = 0; function updateTimeUi(posMs) { seek.value = String(Math.round(posMs)); timeLabel.textContent = `${formatMs(posMs)} / ${formatMs(durationMs)}`; } function scheduleNext() { if (!playing) return; if (idx >= outputEntries.length) { playing = false; playBtn.textContent = "Abspielen"; return; } const entry = outputEntries[idx]; const targetMs = entry.t * 1000; const elapsedMs = baseMs + (Date.now() - realStart) * speed; const delay = Math.max(0, (targetMs - elapsedMs) / speed); timer = window.setTimeout(() => { term.write(recB64decode(entry.data)); idx += 1; updateTimeUi(targetMs); scheduleNext(); }, delay); } function play() { if (playing || idx >= outputEntries.length) return; playing = true; playBtn.textContent = "Pause"; realStart = Date.now(); baseMs = idx > 0 ? outputEntries[idx - 1].t * 1000 : 0; scheduleNext(); } function pause() { playing = false; playBtn.textContent = "Abspielen"; if (timer) { window.clearTimeout(timer); timer = null; } } function seekTo(targetMs) { pause(); term.reset(); idx = 0; const targetSec = targetMs / 1000; while (idx < outputEntries.length && outputEntries[idx].t <= targetSec) { term.write(recB64decode(outputEntries[idx].data)); idx += 1; } updateTimeUi(targetMs); } const onPlayPause = () => { if (playing) pause(); else play(); }; const onSeekChange = () => seekTo(Number(seek.value)); const onSpeedChange = () => { speed = Number(speedSelect.value) || 1; if (playing) { pause(); play(); } }; playBtn.textContent = "Abspielen"; playBtn.addEventListener("click", onPlayPause); seek.addEventListener("change", onSeekChange); speedSelect.addEventListener("change", onSpeedChange); updateTimeUi(0); return { destroy() { pause(); playBtn.removeEventListener("click", onPlayPause); seek.removeEventListener("change", onSeekChange); speedSelect.removeEventListener("change", onSpeedChange); speedSelect.classList.add("hidden"); term.dispose(); }, }; } function createRdpPlayer(entries) { const stage = document.getElementById("rec-stage"); const container = el("div", {}); stage.appendChild(container); const guacStream = entries.filter((e) => e.dir === "output").map((e) => e.data).join(""); const blob = new Blob([guacStream], { type: "application/octet-stream" }); const recording = new Guacamole.SessionRecording(blob); container.appendChild(recording.getDisplay().getElement()); const playBtn = document.getElementById("rec-playpause-btn"); const seek = document.getElementById("rec-seek"); const timeLabel = document.getElementById("rec-time"); let posTimer = null; let seeking = false; function updateTimeUi() { const duration = recording.getDuration(); const durationRounded = Math.round(duration); if (Number(seek.max) !== durationRounded) seek.max = String(durationRounded); if (!seeking) seek.value = String(Math.round(recording.getPosition())); timeLabel.textContent = `${formatMs(recording.getPosition())} / ${formatMs(duration)}`; } recording.onload = updateTimeUi; recording.onprogress = updateTimeUi; recording.onplay = () => { playBtn.textContent = "Pause"; if (posTimer) window.clearInterval(posTimer); posTimer = window.setInterval(updateTimeUi, 200); }; recording.onpause = () => { playBtn.textContent = "Abspielen"; if (posTimer) { window.clearInterval(posTimer); posTimer = null; } updateTimeUi(); }; recording.onerror = (message) => { showBanner("Fehler bei der RDP-Wiedergabe: " + message, "error"); }; const onPlayPause = () => { if (recording.isPlaying()) recording.pause(); else recording.play(); }; const onSeekStart = () => { seeking = true; }; const onSeekInput = () => { timeLabel.textContent = `${formatMs(Number(seek.value))} / ${formatMs(recording.getDuration())}`; }; const onSeekCommit = () => { recording.seek(Number(seek.value), () => { seeking = false; updateTimeUi(); }); }; playBtn.textContent = "Abspielen"; playBtn.addEventListener("click", onPlayPause); seek.addEventListener("mousedown", onSeekStart); seek.addEventListener("touchstart", onSeekStart); seek.addEventListener("input", onSeekInput); seek.addEventListener("change", onSeekCommit); updateTimeUi(); return { destroy() { recording.pause(); recording.abort(); if (posTimer) window.clearInterval(posTimer); playBtn.removeEventListener("click", onPlayPause); seek.removeEventListener("mousedown", onSeekStart); seek.removeEventListener("touchstart", onSeekStart); seek.removeEventListener("input", onSeekInput); seek.removeEventListener("change", onSeekCommit); }, }; } async function showSessionRecording(session) { const overlay = document.getElementById("rec-overlay"); const title = document.getElementById("rec-title"); const loading = document.getElementById("rec-loading"); const stage = document.getElementById("rec-stage"); const controls = document.getElementById("rec-controls"); const verifyBanner = document.getElementById("rec-verify-banner"); closeRecordingOverlay(); overlay.classList.remove("hidden"); title.textContent = `Wiedergabe -- Sitzung #${session.id} (${session.username} @ ${session.hostname})`; loading.classList.remove("hidden"); loading.textContent = "Aufzeichnung wird geladen ..."; stage.classList.add("hidden"); controls.classList.add("hidden"); verifyBanner.classList.add("hidden"); try { const result = await getJson(`/admin/sessions/${session.id}/recording/entries`); loading.classList.add("hidden"); stage.classList.remove("hidden"); controls.classList.remove("hidden"); if (!result.verified) { verifyBanner.className = "banner error"; verifyBanner.textContent = "WARNUNG: Hash-Kette dieser Aufzeichnung ist gebrochen -- moeglicherweise nachtraeglich veraendert oder beschaedigt. Wiedergabe ist trotzdem moeglich."; verifyBanner.classList.remove("hidden"); } if (result.entries.length === 0) { loading.classList.remove("hidden"); loading.textContent = "Diese Aufzeichnung enthaelt keine Eintraege."; stage.classList.add("hidden"); controls.classList.add("hidden"); return; } activePlayer = result.protocol === "rdp" ? createRdpPlayer(result.entries) : createSshPlayer(result.entries); } catch (err) { loading.classList.remove("hidden"); loading.textContent = "Fehler beim Laden der Aufzeichnung: " + err.message; } } // --------------------------------------------------------------------- // Verbindungslog (nur Super-Admin) -- Live-Tail der Anwendungslogs // (inkl. Debug fuer SSH/RDP-Verbindungsaufbau) per WebSocket. // --------------------------------------------------------------------- let logSocket = null; let logLines = []; const LOG_MAX_LINES = 2000; function connectLogStream() { if (logSocket && (logSocket.readyState === WebSocket.OPEN || logSocket.readyState === WebSocket.CONNECTING)) { return; } const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; logSocket = new WebSocket(`${proto}//${window.location.host}/ws/logs`); setConnLogStatus("verbinde..."); logSocket.addEventListener("open", () => setConnLogStatus("verbunden")); logSocket.addEventListener("close", () => setConnLogStatus("getrennt")); logSocket.addEventListener("error", () => setConnLogStatus("Fehler")); logSocket.addEventListener("message", (ev) => { try { const msg = JSON.parse(ev.data); if (msg.type === "line") appendLogLine(msg.line); } catch (_err) { // ungueltige Nachricht ignorieren } }); } function disconnectLogStream() { if (logSocket) { logSocket.close(); logSocket = null; } setConnLogStatus("getrennt"); } function setConnLogStatus(text) { const el2 = document.getElementById("connlog-status"); if (el2) el2.textContent = text; } function appendLogLine(line) { logLines.push(line); if (logLines.length > LOG_MAX_LINES) logLines = logLines.slice(-LOG_MAX_LINES); renderConnLog(); } function renderConnLog() { const pre = document.getElementById("connlog-output"); if (!pre) return; const filter = document.getElementById("connlog-filter").value.trim().toLowerCase(); const filtered = filter ? logLines.filter((l) => l.toLowerCase().includes(filter)) : logLines; const wasAtBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 20; pre.textContent = filtered.join("\n"); if (wasAtBottom) pre.scrollTop = pre.scrollHeight; } async function loadConnLogTab() { connectLogStream(); document.getElementById("connlog-filter").addEventListener("input", renderConnLog); document.getElementById("connlog-clear-btn").addEventListener("click", () => { logLines = []; renderConnLog(); }); } // --------------------------------------------------------------------- // Audit-Log // --------------------------------------------------------------------- async function loadAuditTab() { await refreshAuditLog(); } async function refreshAuditLog() { const entries = await getJson("/admin/audit-log?limit=200"); const tbody = document.querySelector("#audit-table tbody"); fillTable( tbody, entries.map((e) => el("tr", {}, [ el("td", { textContent: String(e.id) }), el("td", { textContent: e.ts }), el("td", { textContent: e.user_id === null ? "-" : String(e.user_id) }), el("td", { textContent: e.client_ip || "-" }), el("td", { textContent: e.event_type }), el("td", {}, [el("pre", { className: "json-view", textContent: e.details })]), ]) ) ); } document.getElementById("audit-verify-btn").addEventListener("click", async () => { try { const result = await getJson("/admin/audit-log/verify"); const box = document.getElementById("audit-verify-result"); box.classList.remove("hidden"); box.className = `banner ${result.intact ? "ok" : "error"}`; box.textContent = result.intact ? "Audit-Kette ist intakt." : `Audit-Kette ist ab Eintrag ${result.first_broken_id} manipuliert!`; } catch (err) { showBanner(err.message, "error"); } }); // --------------------------------------------------------------------- // Gemeinsame Selects (User/Hostgruppen/Gruppen) auf dem aktuellen Stand halten // --------------------------------------------------------------------- async function refreshSharedSelects() { if (cachedUsers.length === 0) await refreshUsers(); if (cachedHostGroups.length === 0) await refreshHostGroups(); if (cachedGroups.length === 0) await refreshGroups(); const userOpts = () => optionsFromList(cachedUsers, "id", (u) => `${u.username} (#${u.id})`); const hostGroupOpts = () => optionsFromList(cachedHostGroups, "id", (g) => `${g.name} (#${g.id})`); const groupOpts = () => optionsFromList(cachedGroups, "id", (g) => `${g.name} (#${g.id})`); const grgGroup = document.getElementById("grg-group"); if (grgGroup) grgGroup.replaceChildren(...groupOpts()); const gcgGroup = document.getElementById("gcg-group"); if (gcgGroup) gcgGroup.replaceChildren(...groupOpts()); const grgHostgroup = document.getElementById("grg-hostgroup"); if (grgHostgroup) grgHostgroup.replaceChildren(...hostGroupOpts()); const tcUser = document.getElementById("tc-user"); if (tcUser) tcUser.replaceChildren(...userOpts()); } // --------------------------------------------------------------------- // Start // --------------------------------------------------------------------- document.getElementById("logout-btn").addEventListener("click", async () => { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); window.location.href = "/"; }); async function main() { const me = await getJson("/auth/me"); if (!me.is_admin) { window.location.href = "/dashboard"; return; } meInfo = me; document.getElementById("whoami").textContent = `${me.username} (Admin)`; document.getElementById("sessions-tab-btn").classList.toggle("hidden", !me.is_admin); document.getElementById("connlog-tab-btn").classList.toggle("hidden", !me.is_admin); loadedTabs.add("users"); await loadUsersTab(); } main().catch((err) => showBanner(err.message, "error")); })();