Ephemeral keys for xterm.js. Initial rework of audit logging. All endpoints now return a 401 regardless of presence if not logged in.
This commit is contained in:
@@ -10,6 +10,13 @@ from channels.db import database_sync_to_async
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.audit.matching import find_matching_event_type
|
||||
from apps.audit.models import AuditEventType, AuditLog
|
||||
from apps.audit.utils import (
|
||||
get_client_ip_from_scope,
|
||||
get_request_id_from_scope,
|
||||
get_user_agent_from_scope,
|
||||
)
|
||||
from apps.keys.certificates import get_active_ca, _sign_public_key
|
||||
from apps.keys.utils import render_system_username
|
||||
from apps.servers.models import Server, ServerAccount
|
||||
@@ -18,11 +25,14 @@ from apps.servers.permissions import user_can_shell
|
||||
|
||||
class ShellConsumer(AsyncWebsocketConsumer):
|
||||
async def connect(self):
|
||||
# Initialize per-connection state; this consumer is stateful
|
||||
# across the WebSocket lifecycle.
|
||||
self.proc = None
|
||||
self.reader_task = None
|
||||
self.tempdir = None
|
||||
self.system_username = ""
|
||||
self.shell_target = ""
|
||||
self.server_id: int | None = None
|
||||
|
||||
user = self.scope.get("user")
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
@@ -32,10 +42,13 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
||||
if not server_id:
|
||||
await self.close(code=4400)
|
||||
return
|
||||
# Resolve the server and enforce object-level permissions before
|
||||
# accepting the socket.
|
||||
server = await self._get_server(user, int(server_id))
|
||||
if not server:
|
||||
await self.close(code=4404)
|
||||
return
|
||||
self.server_id = server.id
|
||||
can_shell = await self._can_shell(user, server)
|
||||
if not can_shell:
|
||||
await self.close(code=4403)
|
||||
@@ -49,6 +62,8 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
||||
self.shell_target = shell_target
|
||||
|
||||
await self.accept()
|
||||
# Audit the WebSocket connection as an explicit, opt-in event.
|
||||
await self._audit_websocket_event(user=user, action="connect", metadata={"server_id": server.id})
|
||||
await self.send(text_data="Connecting...\r\n")
|
||||
try:
|
||||
await self._start_ssh(user)
|
||||
@@ -57,6 +72,13 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
||||
await self.close()
|
||||
|
||||
async def disconnect(self, code):
|
||||
user = self.scope.get("user")
|
||||
if user and getattr(user, "is_authenticated", False):
|
||||
await self._audit_websocket_event(
|
||||
user=user,
|
||||
action="disconnect",
|
||||
metadata={"code": code, "server_id": self.server_id},
|
||||
)
|
||||
if self.reader_task:
|
||||
self.reader_task.cancel()
|
||||
self.reader_task = None
|
||||
@@ -84,6 +106,8 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
||||
await self.proc.stdin.drain()
|
||||
|
||||
async def _start_ssh(self, user):
|
||||
# Generate a short-lived keypair + SSH certificate and then
|
||||
# bridge the WebSocket to an SSH subprocess.
|
||||
self.tempdir = tempfile.TemporaryDirectory(prefix="keywarden-shell-")
|
||||
key_path, cert_path = await asyncio.to_thread(
|
||||
_generate_session_keypair,
|
||||
@@ -91,6 +115,7 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
||||
user,
|
||||
self.system_username,
|
||||
)
|
||||
ssh_host = _format_ssh_host(self.shell_target)
|
||||
command = [
|
||||
"ssh",
|
||||
"-tt",
|
||||
@@ -109,10 +134,16 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
||||
"-o",
|
||||
"PreferredAuthentications=publickey",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"GlobalKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
f"{self.system_username}@{self.shell_target}",
|
||||
"VerifyHostKeyDNS=no",
|
||||
"-o",
|
||||
"LogLevel=ERROR",
|
||||
f"{self.system_username}@{ssh_host}",
|
||||
"/bin/bash",
|
||||
]
|
||||
self.proc = await asyncio.create_subprocess_exec(
|
||||
@@ -154,8 +185,46 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
||||
return account.system_username
|
||||
return render_system_username(user.username, user.id)
|
||||
|
||||
@database_sync_to_async
|
||||
def _audit_websocket_event(self, user, action: str, metadata: dict | None = None) -> None:
|
||||
try:
|
||||
path = str(self.scope.get("path") or "")
|
||||
client_ip = get_client_ip_from_scope(self.scope)
|
||||
# Match only against explicitly configured WebSocket event types.
|
||||
event_type = find_matching_event_type(
|
||||
kind=AuditEventType.Kind.WEBSOCKET,
|
||||
method="GET",
|
||||
route=path,
|
||||
path=path,
|
||||
ip=client_ip,
|
||||
)
|
||||
if event_type is None:
|
||||
return
|
||||
combined_metadata = {
|
||||
"action": action,
|
||||
"path": path,
|
||||
}
|
||||
if metadata:
|
||||
combined_metadata.update(metadata)
|
||||
AuditLog.objects.create(
|
||||
created_at=timezone.now(),
|
||||
actor=user,
|
||||
event_type=event_type,
|
||||
message=f"WebSocket {action} {path}",
|
||||
severity=event_type.default_severity,
|
||||
source=AuditLog.Source.API,
|
||||
ip_address=client_ip,
|
||||
user_agent=get_user_agent_from_scope(self.scope),
|
||||
request_id=get_request_id_from_scope(self.scope),
|
||||
metadata=combined_metadata,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _generate_session_keypair(tempdir: str, user, principal: str) -> tuple[str, str]:
|
||||
# Create an ephemeral SSH keypair and sign it with the active CA so
|
||||
# the user gets time-scoped shell access without long-lived keys.
|
||||
ca = get_active_ca(created_by=user)
|
||||
serial = secrets.randbits(63)
|
||||
identity = f"keywarden-shell-{user.id}-{serial}"
|
||||
@@ -195,3 +264,10 @@ def _generate_session_keypair(tempdir: str, user, principal: str) -> tuple[str,
|
||||
handle.write(cert_text + "\n")
|
||||
os.chmod(cert_path, 0o644)
|
||||
return key_path, cert_path
|
||||
|
||||
|
||||
def _format_ssh_host(host: str) -> str:
|
||||
# IPv6 hosts must be wrapped in brackets for the SSH CLI.
|
||||
if ":" in host and not (host.startswith("[") and host.endswith("]")):
|
||||
return f"[{host}]"
|
||||
return host
|
||||
|
||||
20
app/apps/servers/migrations/0007_server_host_key.py
Normal file
20
app/apps/servers/migrations/0007_server_host_key.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("servers", "0006_remove_user_group_server_perms"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="server",
|
||||
name="ssh_host_public_key",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="server",
|
||||
name="ssh_host_fingerprint",
|
||||
field=models.CharField(blank=True, max_length=128),
|
||||
),
|
||||
]
|
||||
18
app/apps/servers/migrations/0008_remove_server_host_key.py
Normal file
18
app/apps/servers/migrations/0008_remove_server_host_key.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("servers", "0007_server_host_key"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="server",
|
||||
name="ssh_host_fingerprint",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="server",
|
||||
name="ssh_host_public_key",
|
||||
),
|
||||
]
|
||||
@@ -1,38 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Shell • {{ server.display_name }} • Keywarden{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.4.0/css/xterm.css">
|
||||
<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 %}
|
||||
<div class="space-y-8">
|
||||
{% if not is_popout %}
|
||||
{% include "servers/_header.html" %}
|
||||
{% else %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-900">Shell • {{ server.display_name }}</h1>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="text-sm font-semibold text-gray-500 hover:text-gray-700" onclick="window.close()">
|
||||
Close
|
||||
</button>
|
||||
{% if is_popout %}
|
||||
<div class="w-screen">
|
||||
<div id="shell-popout-shell" class="w-full border border-gray-200 bg-gray-900 shadow-sm">
|
||||
<div id="shell-terminal" class="h-full w-full p-2"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="space-y-8">
|
||||
{% include "servers/_header.html" %}
|
||||
|
||||
<section class="rounded-2xl border border-gray-200 bg-white p-5 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-600">
|
||||
Connect with your private key and the signed certificate for this server.
|
||||
</p>
|
||||
</div>
|
||||
{% if not is_popout %}
|
||||
<section class="rounded-2xl border border-gray-200 bg-white p-5 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-600">
|
||||
Connect with your private key and the signed certificate for this server.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-md border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50"
|
||||
@@ -41,101 +42,110 @@
|
||||
>
|
||||
Pop out terminal
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-4 text-sm text-gray-600">
|
||||
<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-dashed 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 text-gray-500">SSH command</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-purple-700 hover:text-purple-800"
|
||||
data-copy-target="shell-command"
|
||||
>
|
||||
Copy command
|
||||
</button>
|
||||
<div class="mt-4 space-y-4 text-sm text-gray-600">
|
||||
<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>
|
||||
<code class="mt-2 block break-all text-xs text-gray-800" id="shell-command">{{ shell_command }}</code>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{% if certificate_key_id %}
|
||||
<div class="inline-flex overflow-hidden rounded-md shadow-sm">
|
||||
<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-dashed 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 text-gray-500">SSH command</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-l-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700"
|
||||
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate"
|
||||
class="text-xs font-semibold text-purple-700 hover:text-purple-800"
|
||||
data-copy-target="shell-command"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-r-md border border-l-0 border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-200"
|
||||
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate.sha256"
|
||||
>
|
||||
Hash
|
||||
Copy command
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700 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>
|
||||
{% 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-5 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-600">
|
||||
Launch a proxied terminal session to the target host in your browser.
|
||||
</p>
|
||||
<code class="mt-2 block break-all text-xs text-gray-800" id="shell-command">{{ shell_command }}</code>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{% if certificate_key_id %}
|
||||
<div class="inline-flex overflow-hidden rounded-md shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-l-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700"
|
||||
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-r-md border border-l-0 border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-200"
|
||||
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate.sha256"
|
||||
>
|
||||
Hash
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700 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>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-600">Upload a key to enable downloads and a local SSH command.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="text-xs font-semibold text-gray-500">Beta</span>
|
||||
</div>
|
||||
<div class="mt-4 rounded-xl border border-gray-200 bg-gray-900 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>
|
||||
</section>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.4.0/lib/xterm.js"></script>
|
||||
<section class="rounded-2xl border border-gray-200 bg-white p-5 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-600">
|
||||
Launch a proxied terminal session to the target host in your browser.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold text-gray-500">Beta</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700"
|
||||
id="shell-start"
|
||||
>
|
||||
Start terminal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 rounded-xl border border-gray-200 bg-gray-900 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) {
|
||||
@@ -227,7 +237,77 @@
|
||||
}
|
||||
|
||||
var termContainer = document.getElementById("shell-terminal");
|
||||
if (termContainer && window.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-purple-600", !isRunning);
|
||||
startButton.classList.toggle("hover:bg-purple-700", !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",
|
||||
@@ -239,12 +319,16 @@
|
||||
}
|
||||
});
|
||||
term.open(termContainer);
|
||||
term.write("Connecting...\r\n");
|
||||
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") {
|
||||
@@ -258,6 +342,9 @@
|
||||
|
||||
socket.onclose = function () {
|
||||
term.write("\r\nSession closed.\r\n");
|
||||
if (activeSocket === socket) {
|
||||
stopTerminal();
|
||||
}
|
||||
};
|
||||
|
||||
term.onData(function (data) {
|
||||
@@ -265,6 +352,37 @@
|
||||
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>
|
||||
|
||||
@@ -62,6 +62,8 @@ def dashboard(request):
|
||||
@login_required(login_url="/accounts/login/")
|
||||
def detail(request, server_id: int):
|
||||
now = timezone.now()
|
||||
# Authorization is enforced via object-level permissions before we do
|
||||
# any other server-specific work.
|
||||
server = _get_server_or_404(request, server_id)
|
||||
can_shell = user_can_shell(request.user, server, now)
|
||||
|
||||
@@ -94,6 +96,8 @@ def detail(request, server_id: int):
|
||||
@login_required(login_url="/accounts/login/")
|
||||
def shell(request, server_id: int):
|
||||
server = _get_server_or_404(request, server_id)
|
||||
# We intentionally return a 404 on denied shell access to avoid
|
||||
# disclosing that the server exists but is restricted.
|
||||
if not user_can_shell(request.user, server):
|
||||
raise Http404("Shell access not available")
|
||||
_, system_username, certificate_key_id = _load_account_context(request, server)
|
||||
@@ -145,6 +149,8 @@ def settings(request, server_id: int):
|
||||
|
||||
|
||||
def _get_server_or_404(request, server_id: int) -> Server:
|
||||
# Centralized object lookup + permission gate. We raise 404 for both
|
||||
# missing objects and permission denials to reduce enumeration signals.
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
except Server.DoesNotExist:
|
||||
@@ -155,6 +161,8 @@ def _get_server_or_404(request, server_id: int) -> Server:
|
||||
|
||||
|
||||
def _load_account_context(request, server: Server):
|
||||
# Resolve the effective system username and the currently active SSH
|
||||
# key/certificate context used by the shell UI.
|
||||
account = ServerAccount.objects.filter(server=server, user=request.user).first()
|
||||
system_username = account.system_username if account else render_system_username(
|
||||
request.user.username, request.user.id
|
||||
@@ -162,4 +170,3 @@ def _load_account_context(request, server: Server):
|
||||
active_key = SSHKey.objects.filter(user=request.user, is_active=True).order_by("-created_at").first()
|
||||
certificate_key_id = active_key.id if active_key else None
|
||||
return account, system_username, certificate_key_id
|
||||
|
||||
|
||||
Reference in New Issue
Block a user