Files
keywarden/app/apps/servers/templates/servers/shell.html
T
2026-02-03 09:54:49 +00:00

390 lines
14 KiB
HTML

{% extends "base.html" %}
{% load static %}
{% block title %}Shell • {{ server.display_name }} • Keywarden{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'vendor/xterm/xterm.css' %}">
{% if is_popout %}
<style>
body.popout-shell main {
max-width: none !important;
padding: 0 !important;
}
</style>
{% endif %}
{% endblock %}
{% block content %}
{% if is_popout %}
<div class="w-screen">
<div id="shell-popout-shell" class="w-full rounded-2xl border border-gray-200 bg-slate-950 shadow-sm">
<div id="shell-terminal" class="h-full w-full p-3"></div>
</div>
</div>
{% else %}
<div class="space-y-6">
{% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">Shell access</h2>
<p class="mt-1 text-sm text-gray-500">
Connect with your private key and the signed certificate for this server.
</p>
</div>
<button
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
id="shell-popout"
data-popout-url="{% url 'servers:shell' server.id %}?popout=1"
>
Pop out terminal
</button>
</div>
<div class="mt-5 grid gap-4 text-sm text-gray-600 lg:grid-cols-2">
<dl class="space-y-4">
<div class="flex items-center justify-between">
<dt>Account name</dt>
<dd class="font-medium text-gray-900">
{% if system_username %}
{{ system_username }}
{% else %}
Unknown
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between">
<dt>Host</dt>
<dd class="font-medium text-gray-900">
{% if shell_target %}
{{ shell_target }}
{% else %}
Unknown
{% endif %}
</dd>
</div>
</dl>
{% if shell_command %}
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">SSH command</span>
<button
type="button"
class="text-xs font-semibold text-blue-700 hover:underline"
data-copy-target="shell-command"
>
Copy command
</button>
</div>
<code class="mt-3 block break-all rounded-lg bg-white p-3 text-xs text-gray-800" id="shell-command">{{ shell_command }}</code>
<div class="mt-4 flex flex-wrap items-center gap-2">
{% if certificate_key_id %}
<div class="inline-flex rounded-lg shadow-sm" role="group">
<button
type="button"
class="inline-flex items-center rounded-l-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-300 js-regenerate-cert"
data-key-id="{{ certificate_key_id }}"
>
Regenerate
</button>
{% endif %}
<span class="text-xs text-gray-500">Use the command above for local SSH.</span>
</div>
</div>
{% else %}
<p class="text-sm text-gray-600">Upload a key to enable downloads and a local SSH command.</p>
{% endif %}
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">Browser terminal</h2>
<p class="mt-1 text-sm text-gray-500">
Launch a proxied terminal session to the target host in your browser.
</p>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">Beta</span>
<button
type="button"
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
id="shell-start"
>
Start terminal
</button>
</div>
</div>
<div class="mt-4 rounded-xl border border-gray-200 bg-slate-950 p-2">
<div id="shell-terminal" class="h-96"></div>
</div>
<p class="mt-3 text-xs text-gray-500">
Sessions are proxied through Keywarden and end when this page closes.
</p>
</section>
</div>
{% endif %}
<script src="{% static 'vendor/xterm/xterm.js' %}"></script>
<script>
(function () {
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
}
return "";
}
function handleDownload(event) {
var button = event.currentTarget;
var url = button.getAttribute("data-download-url");
if (!url) {
return;
}
window.location.href = url;
}
function handleRegenerate(event) {
var button = event.currentTarget;
var keyId = button.getAttribute("data-key-id");
if (!keyId) {
return;
}
if (!window.confirm("Regenerate the certificate for this key?")) {
return;
}
var csrf = getCookie("csrftoken");
fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRFToken": csrf,
},
})
.then(function (response) {
if (!response.ok) {
throw new Error("Certificate regeneration failed.");
}
window.alert("Certificate regenerated.");
})
.catch(function (err) {
window.alert(err.message);
});
}
function handleCopy(event) {
var targetId = event.currentTarget.getAttribute("data-copy-target");
if (!targetId) {
return;
}
var node = document.getElementById(targetId);
if (!node) {
return;
}
var text = node.textContent || "";
if (!navigator.clipboard || !text) {
return;
}
navigator.clipboard.writeText(text).then(function () {
window.alert("Command copied.");
});
}
var popout = document.getElementById("shell-popout");
if (popout) {
popout.addEventListener("click", function () {
var url = popout.getAttribute("data-popout-url");
if (!url) {
return;
}
window.open(url, "_blank", "width=900,height=700");
});
}
var downloadButtons = document.querySelectorAll("[data-download-url]");
for (var i = 0; i < downloadButtons.length; i += 1) {
downloadButtons[i].addEventListener("click", handleDownload);
}
var buttons = document.querySelectorAll(".js-regenerate-cert");
for (var j = 0; j < buttons.length; j += 1) {
buttons[j].addEventListener("click", handleRegenerate);
}
var copyButtons = document.querySelectorAll("[data-copy-target]");
for (var k = 0; k < copyButtons.length; k += 1) {
copyButtons[k].addEventListener("click", handleCopy);
}
var termContainer = document.getElementById("shell-terminal");
var startButton = document.getElementById("shell-start");
var activeSocket = null;
var activeTerm = null;
var popoutShell = document.getElementById("shell-popout-shell");
var isPopout = {{ is_popout|yesno:"true,false" }};
function sizePopoutTerminal() {
if (!isPopout || !popoutShell || !termContainer) {
return;
}
var padding = 24;
var height = Math.max(320, window.innerHeight - padding);
popoutShell.style.height = height + "px";
termContainer.style.height = (height - 8) + "px";
}
function fitTerminal(term) {
if (!termContainer || !term || !term._core || !term._core._renderService) {
return;
}
var dims = term._core._renderService.dimensions;
if (!dims || !dims.css || !dims.css.cell) {
return;
}
var cellWidth = dims.css.cell.width || 9;
var cellHeight = dims.css.cell.height || 18;
if (!cellWidth || !cellHeight) {
return;
}
var cols = Math.max(20, Math.floor(termContainer.clientWidth / cellWidth));
var rows = Math.max(10, Math.floor(termContainer.clientHeight / cellHeight));
term.resize(cols, rows);
}
function setButtonState(isRunning) {
if (!startButton) {
return;
}
startButton.disabled = false;
startButton.textContent = isRunning ? "Stop terminal" : "Start terminal";
startButton.classList.toggle("bg-red-600", isRunning);
startButton.classList.toggle("hover:bg-red-700", isRunning);
startButton.classList.toggle("bg-blue-700", !isRunning);
startButton.classList.toggle("hover:bg-blue-800", !isRunning);
}
function stopTerminal() {
if (activeSocket) {
try {
activeSocket.close();
} catch (err) {
// noop
}
}
if (termContainer) {
termContainer.dataset.started = "0";
}
activeSocket = null;
activeTerm = null;
setButtonState(false);
}
function startTerminal() {
if (!termContainer || !window.Terminal || termContainer.dataset.started === "1") {
return;
}
termContainer.dataset.started = "1";
if (startButton) {
startButton.disabled = true;
startButton.textContent = "Starting...";
}
var term = new window.Terminal({
cursorBlink: true,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: 13,
theme: {
background: "#0b1120",
foreground: "#e2e8f0",
cursor: "#38bdf8"
}
});
term.open(termContainer);
setTimeout(function () {
fitTerminal(term);
}, 0);
var protocol = window.location.protocol === "https:" ? "wss" : "ws";
var socketUrl = protocol + "://" + window.location.host + "/ws/servers/{{ server.id }}/shell/";
var socket = new WebSocket(socketUrl);
socket.binaryType = "arraybuffer";
activeSocket = socket;
activeTerm = term;
socket.onmessage = function (event) {
if (typeof event.data === "string") {
term.write(event.data);
return;
}
var data = new Uint8Array(event.data);
var text = new TextDecoder("utf-8").decode(data);
term.write(text);
};
socket.onclose = function () {
term.write("\r\nSession closed.\r\n");
if (activeSocket === socket) {
stopTerminal();
}
};
term.onData(function (data) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data);
}
});
setButtonState(true);
if (isPopout) {
var onResize = function () {
sizePopoutTerminal();
fitTerminal(term);
};
window.addEventListener("resize", onResize);
}
}
if (termContainer && window.Terminal) {
if (isPopout) {
document.body.classList.add("popout-shell");
sizePopoutTerminal();
window.addEventListener("resize", sizePopoutTerminal);
}
if (startButton) {
startButton.addEventListener("click", function () {
if (termContainer.dataset.started === "1") {
stopTerminal();
return;
}
startTerminal();
});
} else {
startTerminal();
}
} else if (termContainer) {
termContainer.textContent = "Terminal assets failed to load.";
}
})();
</script>
{% endblock %}