second commit
This commit is contained in:
90
static/css/app.css
Normal file
90
static/css/app.css
Normal 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
74
static/js/dashboard.js
Normal 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
85
static/js/login.js
Normal 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
80
static/js/rdp.js
Normal 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
100
static/js/terminal.js
Normal 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 = "";
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user