second commit

This commit is contained in:
2026-08-19 22:33:19 +02:00
parent 411812e954
commit 199f306993
107 changed files with 5984 additions and 0 deletions

74
static/js/dashboard.js Normal file
View File

@ -0,0 +1,74 @@
(() => {
"use strict";
async function getJson(url) {
const res = await fetch(url, { credentials: "same-origin" });
if (res.status === 401) {
window.location.href = "/";
throw new Error("nicht angemeldet");
}
return res.json();
}
function hostCard(host) {
const div = document.createElement("div");
div.className = "host-card";
const name = document.createElement("div");
name.className = "hostname";
name.textContent = host.hostname;
div.appendChild(name);
const meta = document.createElement("div");
meta.className = "meta";
meta.textContent = `${host.protocol.toUpperCase()} · ${host.os_type} · ${host.address}`;
div.appendChild(meta);
const actions = document.createElement("div");
actions.className = "actions";
const connect = document.createElement("a");
connect.href = host.protocol === "ssh" ? `/terminal/${host.id}` : `/rdp/${host.id}`;
connect.textContent = "Verbinden";
actions.appendChild(connect);
div.appendChild(actions);
return div;
}
async function main() {
const me = await getJson("/auth/me");
document.getElementById("whoami").textContent = `${me.username}${me.is_admin ? " (Admin)" : ""}`;
const hosts = await getJson("/catalog/hosts");
const byGroup = {};
for (const h of hosts) {
(byGroup[h.host_group_name] = byGroup[h.host_group_name] || []).push(h);
}
const container = document.getElementById("groups");
if (hosts.length === 0) {
container.innerHTML = '<p class="hint">Keine Hosts zugewiesen. Bitte an einen Administrator wenden.</p>';
return;
}
for (const [groupName, groupHosts] of Object.entries(byGroup)) {
const section = document.createElement("div");
section.className = "group";
const h2 = document.createElement("h2");
h2.textContent = groupName;
section.appendChild(h2);
const list = document.createElement("div");
list.className = "host-list";
for (const host of groupHosts) list.appendChild(hostCard(host));
section.appendChild(list);
container.appendChild(section);
}
}
document.getElementById("logout-btn").addEventListener("click", async () => {
await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
window.location.href = "/";
});
main().catch((err) => console.error(err));
})();

85
static/js/login.js Normal file
View File

@ -0,0 +1,85 @@
(() => {
"use strict";
const form = document.getElementById("login-form");
const passwordFields = document.getElementById("password-fields");
const totpFields = document.getElementById("totp-fields");
const enrollBox = document.getElementById("enroll-box");
const recoveryBox = document.getElementById("recovery-box");
const errorBox = document.getElementById("error-box");
const submitBtn = document.getElementById("submit-btn");
let pendingToken = null;
let mode = "password"; // password -> totp | enroll_start -> enroll_confirm -> done
function showError(msg) {
errorBox.textContent = msg;
}
async function postJson(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || "Unbekannter Fehler");
}
return data;
}
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
showError("");
try {
if (mode === "password") {
const username = document.getElementById("username").value.trim();
const password = document.getElementById("password").value;
const result = await postJson("/auth/login", { username, password });
pendingToken = result.pending_token;
passwordFields.style.display = "none";
totpFields.style.display = "block";
if (!result.totp_enrolled) {
mode = "enroll_start";
const enroll = await postJson("/auth/totp/enroll/start", { pending_token: pendingToken });
document.getElementById("qr-img").src = "data:image/png;base64," + enroll.qr_png_base64;
enrollBox.style.display = "block";
mode = "enroll_confirm";
submitBtn.textContent = "TOTP bestaetigen & einrichten";
} else {
mode = "totp";
submitBtn.textContent = "Code bestaetigen";
}
return;
}
if (mode === "totp") {
const code = document.getElementById("totp-code").value.trim();
await postJson("/auth/login/totp", { pending_token: pendingToken, code });
window.location.href = "/dashboard";
return;
}
if (mode === "enroll_confirm") {
const code = document.getElementById("totp-code").value.trim();
const result = await postJson("/auth/totp/enroll/confirm", { pending_token: pendingToken, code });
recoveryBox.style.display = "block";
document.getElementById("recovery-codes").textContent = result.recovery_codes.join("\n");
submitBtn.textContent = "Weiter zum Dashboard";
mode = "done";
return;
}
if (mode === "done") {
window.location.href = "/dashboard";
}
} catch (err) {
showError(err.message);
}
});
})();

80
static/js/rdp.js Normal file
View File

@ -0,0 +1,80 @@
/*
* RDP-Session-Client: guacamole-common-js <-> /ws/rdp/{host_id}.
*
* Vollbild: native Browser-Fullscreen-API (Konzept 4.3).
* Copy & Paste: bidirektionale Synchronisation ueber Guacamole.Client
* onclipboard-Event (Ziel -> Browser) und den `paste`-Browser-Event
* (Browser -> Ziel, sendet eine "clipboard"-Instruktion). Wird serverseitig
* zusaetzlich blockiert, wenn fuer den Host clipboard_enabled=false ist
* (siehe app/rdp_proxy/ws_tunnel.py) -- das UI blendet den Hinweis dann ein.
*/
(() => {
"use strict";
const hostId = window.JUMPHOST_HOST_ID;
const statusEl = document.getElementById("status");
const shell = document.getElementById("session-shell");
const displayDiv = document.getElementById("rdp-display");
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const width = Math.round(window.innerWidth);
const height = Math.round(window.innerHeight - 40);
const dpi = Math.round(window.devicePixelRatio * 96) || 96;
const tunnelUrl = `${proto}//${window.location.host}/ws/rdp/${hostId}?width=${width}&height=${height}&dpi=${dpi}`;
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
const client = new Guacamole.Client(tunnel);
displayDiv.appendChild(client.getDisplay().getElement());
client.onstatechange = (state) => {
// 0=idle,1=connecting,2=waiting,3=connected,4=disconnecting,5=disconnected
const labels = ["Idle", "Verbinde ...", "Warte auf Server ...", "Verbunden", "Trenne ...", "Getrennt"];
statusEl.textContent = labels[state] || `Status ${state}`;
};
client.onerror = (err) => {
statusEl.textContent = "Fehler: " + (err.message || "unbekannt");
};
client.onclipboard = (stream, mimetype) => {
if (!mimetype.startsWith("text/")) return;
const reader = new Guacamole.StringReader(stream);
let data = "";
reader.ontext = (text) => { data += text; };
reader.onend = () => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(data).catch(() => {});
}
};
};
client.connect();
window.addEventListener("beforeunload", () => client.disconnect());
const mouse = new Guacamole.Mouse(client.getDisplay().getElement());
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (mouseState) => {
client.sendMouseState(mouseState);
};
const keyboard = new Guacamole.Keyboard(document);
keyboard.onkeydown = (keysym) => client.sendKeyEvent(1, keysym);
keyboard.onkeyup = (keysym) => client.sendKeyEvent(0, keysym);
document.addEventListener("paste", (ev) => {
const text = (ev.clipboardData || window.clipboardData).getData("text");
if (!text) return;
const stream = client.createClipboardStream("text/plain");
const writer = new Guacamole.StringWriter(stream);
writer.sendText(text);
writer.sendEnd();
});
document.getElementById("fullscreen-btn").addEventListener("click", () => {
if (!document.fullscreenElement) {
shell.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen();
}
});
})();

100
static/js/terminal.js Normal file
View File

@ -0,0 +1,100 @@
/*
* SSH-Terminal-Client: xterm.js <-> /ws/ssh/{host_id}.
*
* Vollbild: native Browser-Fullscreen-API auf dem Session-Container (Konzept 4.3).
* Copy & Paste: xterm.js liefert dies bei SSH bereits nativ ueber die
* System-Zwischenablage (Markieren-zum-Kopieren / Strg+Umschalt+V) -- keine
* serverseitige Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js).
*/
(() => {
"use strict";
const hostId = window.JUMPHOST_HOST_ID;
const statusEl = document.getElementById("status");
const shell = document.getElementById("session-shell");
const term = new Terminal({
cursorBlink: true,
fontFamily: "Menlo, Consolas, monospace",
fontSize: 14,
theme: { background: "#000000" },
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();
function b64encode(str) {
return btoa(unescape(encodeURIComponent(str)));
}
function b64decode(b64) {
return decodeURIComponent(escape(atob(b64)));
}
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${window.location.host}/ws/ssh/${hostId}`);
ws.addEventListener("open", () => {
statusEl.textContent = "Verbunden";
sendResize();
});
ws.addEventListener("close", () => { statusEl.textContent = "Verbindung beendet"; });
ws.addEventListener("error", () => { statusEl.textContent = "Verbindungsfehler"; });
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "output") {
term.write(b64decode(msg.data));
} else if (msg.type === "error") {
statusEl.textContent = "Fehler: " + msg.message;
}
});
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data: b64encode(data) }));
}
});
function sendResize() {
fitAddon.fit();
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
}
window.addEventListener("resize", sendResize);
document.getElementById("fullscreen-btn").addEventListener("click", () => {
if (!document.fullscreenElement) {
shell.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen();
}
});
document.addEventListener("fullscreenchange", sendResize);
const fileInput = document.getElementById("file-input");
document.getElementById("upload-btn").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", async () => {
const file = fileInput.files[0];
if (!file) return;
const remotePath = prompt("Zielpfad auf dem Server:", `/tmp/${file.name}`);
if (!remotePath) return;
const formData = new FormData();
formData.append("file", file);
statusEl.textContent = `Lade ${file.name} hoch ...`;
try {
const res = await fetch(
`/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`,
{ method: "POST", credentials: "same-origin", body: formData }
);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Upload fehlgeschlagen");
statusEl.textContent = `Upload ok (AV: ${data.av_scan_result})`;
} catch (err) {
statusEl.textContent = "Upload-Fehler: " + err.message;
}
fileInput.value = "";
});
})();