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

90
static/css/app.css Normal file
View File

@ -0,0 +1,90 @@
:root {
--bg: #0f1115;
--panel: #171a21;
--border: #2a2f3a;
--text: #e6e8eb;
--muted: #9aa3af;
--accent: #3b82f6;
--danger: #ef4444;
--ok: #22c55e;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
.center-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 2rem;
width: 100%;
max-width: 380px;
}
.card h1 { font-size: 1.25rem; margin: 0 0 1.25rem; }
label { display: block; font-size: 0.85rem; color: var(--muted); margin: 0.75rem 0 0.25rem; }
input[type=text], input[type=password] {
width: 100%;
padding: 0.6rem 0.7rem;
background: #0f1218;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.95rem;
}
button {
margin-top: 1.25rem;
width: 100%;
padding: 0.65rem;
background: var(--accent);
border: none;
border-radius: 6px;
color: white;
font-weight: 600;
cursor: pointer;
}
button:hover { filter: brightness(1.1); }
.error { color: var(--danger); font-size: 0.85rem; margin-top: 0.75rem; min-height: 1em; }
.hint { color: var(--muted); font-size: 0.8rem; margin-top: 0.5rem; }
.qr { display: block; margin: 1rem auto; border-radius: 6px; }
.recovery-codes { font-family: monospace; background: #0f1218; padding: 0.75rem; border-radius: 6px; }
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border); background: var(--panel);
}
.topbar .brand { font-weight: 700; }
.topbar button { width: auto; margin: 0; padding: 0.4rem 0.9rem; font-size: 0.85rem; }
.container { padding: 1.25rem; max-width: 960px; margin: 0 auto; }
.group { margin-bottom: 1.5rem; }
.group h2 { font-size: 1rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
.host-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0.75rem; }
.host-card {
background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 1rem;
}
.host-card .hostname { font-weight: 600; }
.host-card .meta { color: var(--muted); font-size: 0.8rem; margin: 0.25rem 0 0.75rem; }
.host-card .actions { display: flex; gap: 0.5rem; }
.host-card .actions a, .host-card .actions button {
flex: 1; text-align: center; text-decoration: none; padding: 0.4rem; border-radius: 6px;
background: var(--accent); color: white; font-size: 0.85rem; margin: 0;
}
.session-shell { display: flex; flex-direction: column; height: 100vh; }
.session-toolbar {
display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.9rem;
background: var(--panel); border-bottom: 1px solid var(--border);
}
.session-toolbar button { width: auto; margin: 0; padding: 0.35rem 0.8rem; font-size: 0.8rem; }
.session-toolbar .spacer { flex: 1; }
.session-toolbar .status { font-size: 0.8rem; color: var(--muted); }
#terminal, #rdp-display { flex: 1; background: black; }
#rdp-display canvas { display: block; margin: 0 auto; }

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 = "";
});
})();