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:
2026-02-03 08:26:37 +00:00
parent 3e17d6412c
commit 667b02f0c3
28 changed files with 1546 additions and 181 deletions

View File

@@ -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