more stuff

This commit is contained in:
2026-08-21 08:45:36 +02:00
parent 5e506c9921
commit ad728ba96c
12 changed files with 1148 additions and 64 deletions

View File

@ -520,10 +520,13 @@
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 eintragen, sonst kommt keine Verbindung zustande.';
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) {
@ -581,6 +584,11 @@
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
? `RDP-Zugangsdaten gesetzt fuer "${host.rdp_credentials_username || "(kein Benutzername)"}" (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).`
: "Noch keine RDP-Zugangsdaten gesetzt.";
@ -704,6 +712,40 @@
}
});
document.getElementById("hrc-delete-btn").addEventListener("click", async () => {
try {
await apiFetch(`/admin/hosts/${activeHostId}/rdp-credentials`, { method: "DELETE" });
showBanner("RDP-Zugangsdaten geloescht.", "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)
// ---------------------------------------------------------------------
@ -727,7 +769,6 @@
el("td", { textContent: k.label }),
el("td", { textContent: k.username || "-" }),
el("td", { textContent: k.key_type }),
el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }),
el("td", { textContent: k.tenant_name }),
el("td", { textContent: k.has_passphrase ? "hinterlegt" : "-" }),
el("td", { textContent: k.created_at }),
@ -754,7 +795,6 @@
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("ske-owner").value = k.owner_user_id === null ? "" : String(k.owner_user_id);
document.getElementById("skr-type").value = k.key_type;
document.getElementById("skp-passphrase").value = "";
document.getElementById("ske-passphrase-state").textContent = k.has_passphrase
@ -799,13 +839,11 @@
document.getElementById("ssh-key-edit-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const ownerRaw = document.getElementById("ske-owner").value.trim();
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,
owner_user_id: ownerRaw ? Number(ownerRaw) : null,
});
showBanner("SSH-Key aktualisiert.", "ok");
await refreshSshKeys();
@ -823,7 +861,7 @@
public_key: document.getElementById("skr-public").value,
passphrase: document.getElementById("skr-passphrase").value || null,
});
showBanner("SSH-Key rotiert.", "ok");
showBanner("Neues Schluesselmaterial gespeichert.", "ok");
ev.target.reset();
await refreshSshKeys();
} catch (err) {
@ -831,15 +869,29 @@
}
});
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 ownerRaw = document.getElementById("skc-owner").value.trim();
const tenantSelect = document.getElementById("skc-tenant");
const payload = {
label: document.getElementById("skc-label").value.trim(),
username: document.getElementById("skc-username").value.trim() || null,
owner_user_id: ownerRaw ? Number(ownerRaw) : null,
key_type: document.getElementById("skc-type").value,
private_key_pem: document.getElementById("skc-private").value,
public_key: document.getElementById("skc-public").value,
@ -1238,7 +1290,7 @@
}
if (s.has_recording) {
actions.appendChild(
actionButton("Aufzeichnung", "btn-secondary", () => showSessionRecording(s.id))
actionButton("Wiedergabe", "btn-secondary", () => showSessionRecording(s))
);
}
return el("tr", {}, [
@ -1260,17 +1312,265 @@
);
}
async function showSessionRecording(sessionId) {
// --- 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.
function recB64decode(b64) {
return decodeURIComponent(escape(atob(b64)));
}
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);
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);
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/${sessionId}/recording`);
const box = document.getElementById("session-recording-box");
box.classList.remove("hidden");
box.className = `banner ${result.verified ? "ok" : "error"}`;
box.textContent = result.verified
? `Aufzeichnung #${sessionId}: Integritaet OK, ${result.entry_count} Eintraege.`
: `Aufzeichnung #${sessionId}: WARNUNG -- Hash-Kette gebrochen, moeglicherweise manipuliert!`;
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) {
showBanner(err.message, "error");
loading.classList.remove("hidden");
loading.textContent = "Fehler beim Laden der Aufzeichnung: " + err.message;
}
}