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
|
||||
|
||||
Reference in New Issue
Block a user