390 lines
14 KiB
HTML
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 %}
|