diff --git a/Dockerfile b/Dockerfile index 480e502..e2bea1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,7 +56,7 @@ COPY nginx/configs/options-* /etc/nginx/ COPY supervisor/supervisord.conf /etc/supervisor/supervisord.conf RUN python manage.py collectstatic --noinput -RUN chmod +x /app/entrypoint.sh /app/scripts/gunicorn.sh +RUN chmod +x /app/entrypoint.sh /app/scripts/gunicorn.sh /app/scripts/daphne.sh # ============================================= # 5. Create users for services diff --git a/agent/internal/accounts/sync.go b/agent/internal/accounts/sync.go index d0d3cfe..b7668fe 100644 --- a/agent/internal/accounts/sync.go +++ b/agent/internal/accounts/sync.go @@ -371,7 +371,7 @@ func writeCAKeyIfChanged(key string) (bool, error) { func ensureSSHDConfig() (bool, error) { content := fmt.Sprintf( - "TrustedUserCAKeys %s\nMatch Group %s\n AuthorizedKeysFile none\n", + "TrustedUserCAKeys %s\nMatch Group %s\n AuthorizedKeysFile none\n PasswordAuthentication no\n ChallengeResponseAuthentication no\n", userCAPath, keywardenGroup, ) diff --git a/agent/keywarden-agent b/agent/keywarden-agent index fd19247..efd53d9 100755 Binary files a/agent/keywarden-agent and b/agent/keywarden-agent differ diff --git a/app/apps/servers/consumers.py b/app/apps/servers/consumers.py new file mode 100644 index 0000000..c5b9c6d --- /dev/null +++ b/app/apps/servers/consumers.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import asyncio +import os +import secrets +import subprocess +import tempfile + +from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncWebsocketConsumer +from django.utils import timezone + +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 +from apps.servers.permissions import user_can_shell + + +class ShellConsumer(AsyncWebsocketConsumer): + async def connect(self): + self.proc = None + self.reader_task = None + self.tempdir = None + self.system_username = "" + self.shell_target = "" + + user = self.scope.get("user") + if not user or not getattr(user, "is_authenticated", False): + await self.close(code=4401) + return + server_id = self.scope.get("url_route", {}).get("kwargs", {}).get("server_id") + if not server_id: + await self.close(code=4400) + return + server = await self._get_server(user, int(server_id)) + if not server: + await self.close(code=4404) + return + can_shell = await self._can_shell(user, server) + if not can_shell: + await self.close(code=4403) + return + system_username = await self._get_system_username(user, server) + shell_target = server.hostname or server.ipv4 or server.ipv6 + if not system_username or not shell_target: + await self.close(code=4400) + return + self.system_username = system_username + self.shell_target = shell_target + + await self.accept() + await self.send(text_data="Connecting...\r\n") + try: + await self._start_ssh(user) + except Exception: + await self.send(text_data="Connection failed.\r\n") + await self.close() + + async def disconnect(self, code): + if self.reader_task: + self.reader_task.cancel() + self.reader_task = None + if self.proc and self.proc.returncode is None: + self.proc.terminate() + try: + await asyncio.wait_for(self.proc.wait(), timeout=2.0) + except asyncio.TimeoutError: + self.proc.kill() + if self.tempdir: + self.tempdir.cleanup() + self.tempdir = None + + async def receive(self, text_data=None, bytes_data=None): + if not self.proc or not self.proc.stdin: + return + if bytes_data is not None: + data = bytes_data + elif text_data is not None: + data = text_data.encode("utf-8") + else: + return + if data: + self.proc.stdin.write(data) + await self.proc.stdin.drain() + + async def _start_ssh(self, user): + self.tempdir = tempfile.TemporaryDirectory(prefix="keywarden-shell-") + key_path, cert_path = await asyncio.to_thread( + _generate_session_keypair, + self.tempdir.name, + user, + self.system_username, + ) + command = [ + "ssh", + "-tt", + "-i", + key_path, + "-o", + f"CertificateFile={cert_path}", + "-o", + "BatchMode=yes", + "-o", + "PasswordAuthentication=no", + "-o", + "KbdInteractiveAuthentication=no", + "-o", + "ChallengeResponseAuthentication=no", + "-o", + "PreferredAuthentications=publickey", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + f"{self.system_username}@{self.shell_target}", + "/bin/bash", + ] + self.proc = await asyncio.create_subprocess_exec( + *command, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + self.reader_task = asyncio.create_task(self._stream_output()) + + async def _stream_output(self): + if not self.proc or not self.proc.stdout: + return + while True: + chunk = await self.proc.stdout.read(4096) + if not chunk: + break + await self.send(bytes_data=chunk) + await self.close() + + @database_sync_to_async + def _get_server(self, user, server_id: int): + try: + server = Server.objects.get(id=server_id) + except Server.DoesNotExist: + return None + if not user.has_perm("servers.view_server", server): + return None + return server + + @database_sync_to_async + def _can_shell(self, user, server) -> bool: + return user_can_shell(user, server, timezone.now()) + + @database_sync_to_async + def _get_system_username(self, user, server) -> str: + account = ServerAccount.objects.filter(server=server, user=user).first() + if account: + return account.system_username + return render_system_username(user.username, user.id) + + +def _generate_session_keypair(tempdir: str, user, principal: str) -> tuple[str, str]: + ca = get_active_ca(created_by=user) + serial = secrets.randbits(63) + identity = f"keywarden-shell-{user.id}-{serial}" + key_path = os.path.join(tempdir, "session_key") + cmd = [ + "ssh-keygen", + "-t", + "ed25519", + "-f", + key_path, + "-C", + identity, + "-N", + "", + ] + try: + subprocess.run(cmd, check=True, capture_output=True) + except FileNotFoundError as exc: + raise RuntimeError("ssh-keygen not available") from exc + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc + pubkey_path = key_path + ".pub" + with open(pubkey_path, "r", encoding="utf-8") as handle: + public_key = handle.read().strip() + cert_text = _sign_public_key( + ca_private_key=ca.private_key, + ca_public_key=ca.public_key, + public_key=public_key, + identity=identity, + principal=principal, + serial=serial, + validity_days=1, + comment=identity, + ) + cert_path = key_path + "-cert.pub" + with open(cert_path, "w", encoding="utf-8") as handle: + handle.write(cert_text + "\n") + os.chmod(cert_path, 0o644) + return key_path, cert_path diff --git a/app/apps/servers/migrations/0005_server_shell_permission.py b/app/apps/servers/migrations/0005_server_shell_permission.py new file mode 100644 index 0000000..ca0634e --- /dev/null +++ b/app/apps/servers/migrations/0005_server_shell_permission.py @@ -0,0 +1,19 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("servers", "0004_server_account"), + ] + + operations = [ + migrations.AlterModelOptions( + name="server", + options={ + "ordering": ["display_name", "hostname", "ipv4", "ipv6"], + "permissions": [("shell_server", "Can access server shell")], + "verbose_name": "Server", + "verbose_name_plural": "Servers", + }, + ), + ] diff --git a/app/apps/servers/migrations/0006_remove_user_group_server_perms.py b/app/apps/servers/migrations/0006_remove_user_group_server_perms.py new file mode 100644 index 0000000..db3d570 --- /dev/null +++ b/app/apps/servers/migrations/0006_remove_user_group_server_perms.py @@ -0,0 +1,35 @@ +from django.db import migrations + + +def remove_user_group_server_perms(apps, schema_editor): + Group = apps.get_model("auth", "Group") + Permission = apps.get_model("auth", "Permission") + ContentType = apps.get_model("contenttypes", "ContentType") + GroupObjectPermission = apps.get_model("guardian", "GroupObjectPermission") + + try: + group = Group.objects.get(name="user") + except Group.DoesNotExist: + return + + try: + content_type = ContentType.objects.get(app_label="servers", model="server") + except ContentType.DoesNotExist: + return + + perm_ids = Permission.objects.filter(content_type=content_type).values_list("id", flat=True) + GroupObjectPermission.objects.filter( + group_id=group.id, + permission_id__in=list(perm_ids), + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("servers", "0005_server_shell_permission"), + ("guardian", "0001_initial"), + ] + + operations = [ + migrations.RunPython(remove_user_group_server_perms, migrations.RunPython.noop), + ] diff --git a/app/apps/servers/models.py b/app/apps/servers/models.py index 37215aa..2273afd 100644 --- a/app/apps/servers/models.py +++ b/app/apps/servers/models.py @@ -35,6 +35,9 @@ class Server(models.Model): ordering = ["display_name", "hostname", "ipv4", "ipv6"] verbose_name = "Server" verbose_name_plural = "Servers" + permissions = [ + ("shell_server", "Can access server shell"), + ] def __str__(self) -> str: primary = self.hostname or self.ipv4 or self.ipv6 or "unassigned" diff --git a/app/apps/servers/permissions.py b/app/apps/servers/permissions.py new file mode 100644 index 0000000..59ea6e0 --- /dev/null +++ b/app/apps/servers/permissions.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from django.db.models import Q +from django.utils import timezone + +from apps.access.models import AccessRequest + + +def user_can_shell(user, server, now=None) -> bool: + if user.has_perm("servers.shell_server", server): + return True + if now is None: + now = timezone.now() + return ( + AccessRequest.objects.filter( + requester=user, + server=server, + status=AccessRequest.Status.APPROVED, + request_shell=True, + ) + .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now)) + .exists() + ) diff --git a/app/apps/servers/templates/servers/_header.html b/app/apps/servers/templates/servers/_header.html new file mode 100644 index 0000000..82cc3ad --- /dev/null +++ b/app/apps/servers/templates/servers/_header.html @@ -0,0 +1,43 @@ +
+
+
+ {{ server.initial }} +
+
+

{{ server.display_name }}

+

+ {{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }} +

+
+
+ Back to servers +
+ + diff --git a/app/apps/servers/templates/servers/audit.html b/app/apps/servers/templates/servers/audit.html new file mode 100644 index 0000000..5ff715c --- /dev/null +++ b/app/apps/servers/templates/servers/audit.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block title %}Audit • {{ server.display_name }} • Keywarden{% endblock %} + +{% block content %} +
+ {% include "servers/_header.html" %} + +
+
+

Audit logs

+ Placeholder +
+
+ Logs will appear here once collection is enabled for this server. +
+
+ +
+
+

Metrics

+ Placeholder +
+
+ Metrics will appear here once collection is enabled for this server. +
+
+
+{% endblock %} diff --git a/app/apps/servers/templates/servers/detail.html b/app/apps/servers/templates/servers/detail.html index 638461a..10c7302 100644 --- a/app/apps/servers/templates/servers/detail.html +++ b/app/apps/servers/templates/servers/detail.html @@ -4,27 +4,7 @@ {% block content %}
-
-
-
- {{ server.initial }} -
-
-

{{ server.display_name }}

-

- {{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }} -

-
-
- Back to servers -
- - + {% include "servers/_header.html" %}
@@ -58,6 +38,18 @@ {% endif %}
+
+
Account status
+
+ {% if account_present is None %} + Unknown + {% elif account_present %} + Present + {% else %} + Not on server + {% endif %} +
+
Certificate
@@ -121,16 +113,6 @@
- -
-
-

Logs

- Placeholder -
-
- Logs will appear here once collection is enabled for this server. -
-
+ +{% endblock %} diff --git a/app/apps/servers/urls.py b/app/apps/servers/urls.py index 4e1ce0d..db9efc7 100644 --- a/app/apps/servers/urls.py +++ b/app/apps/servers/urls.py @@ -7,4 +7,7 @@ app_name = "servers" urlpatterns = [ path("", views.dashboard, name="dashboard"), path("/", views.detail, name="detail"), + path("/audit/", views.audit, name="audit"), + path("/shell/", views.shell, name="shell"), + path("/settings/", views.settings, name="settings"), ] diff --git a/app/apps/servers/views.py b/app/apps/servers/views.py index 6599d87..b02b44c 100644 --- a/app/apps/servers/views.py +++ b/app/apps/servers/views.py @@ -8,8 +8,10 @@ from django.utils import timezone from guardian.shortcuts import get_objects_for_user, get_perms from apps.access.models import AccessRequest +from apps.keys.utils import render_system_username from apps.keys.models import SSHKey from apps.servers.models import Server, ServerAccount +from apps.servers.permissions import user_can_shell @login_required(login_url="/accounts/login/") @@ -60,12 +62,8 @@ def dashboard(request): @login_required(login_url="/accounts/login/") def detail(request, server_id: int): now = timezone.now() - try: - server = Server.objects.get(id=server_id) - except Server.DoesNotExist: - raise Http404("Server not found") - if "view_server" not in get_perms(request.user, server): - raise Http404("Server not found") + server = _get_server_or_404(request, server_id) + can_shell = user_can_shell(request.user, server, now) access = ( AccessRequest.objects.filter( @@ -78,17 +76,90 @@ def detail(request, server_id: int): .first() ) - account = ServerAccount.objects.filter(server=server, user=request.user).first() - active_key = ( - SSHKey.objects.filter(user=request.user, is_active=True).order_by("-created_at").first() - ) + account, system_username, certificate_key_id = _load_account_context(request, server) context = { "server": server, "expires_at": access.expires_at if access else None, "last_accessed": None, "account_present": account.is_present if account else None, "account_synced_at": account.last_synced_at if account else None, - "system_username": account.system_username if account else None, - "certificate_key_id": active_key.id if active_key else None, + "system_username": system_username, + "certificate_key_id": certificate_key_id, + "active_tab": "details", + "can_shell": can_shell, } return render(request, "servers/detail.html", context) + + +@login_required(login_url="/accounts/login/") +def shell(request, server_id: int): + server = _get_server_or_404(request, server_id) + if not user_can_shell(request.user, server): + raise Http404("Shell access not available") + _, system_username, certificate_key_id = _load_account_context(request, server) + shell_target = server.hostname or server.ipv4 or server.ipv6 or "" + cert_filename = "" + if certificate_key_id: + cert_filename = f"keywarden-{request.user.id}-{certificate_key_id}-cert.pub" + command = "" + if shell_target and system_username and certificate_key_id: + command = ( + "ssh -i /path/to/private_key " + f"-o CertificateFile=~/Downloads/{cert_filename} " + f"{system_username}@{shell_target} -t /bin/bash" + ) + context = { + "server": server, + "system_username": system_username, + "certificate_key_id": certificate_key_id, + "shell_target": shell_target, + "shell_command": command, + "cert_filename": cert_filename, + "active_tab": "shell", + "is_popout": request.GET.get("popout") == "1", + "can_shell": True, + } + return render(request, "servers/shell.html", context) + + +@login_required(login_url="/accounts/login/") +def audit(request, server_id: int): + server = _get_server_or_404(request, server_id) + context = { + "server": server, + "active_tab": "audit", + "can_shell": user_can_shell(request.user, server), + } + return render(request, "servers/audit.html", context) + + +@login_required(login_url="/accounts/login/") +def settings(request, server_id: int): + server = _get_server_or_404(request, server_id) + context = { + "server": server, + "active_tab": "settings", + "can_shell": user_can_shell(request.user, server), + } + return render(request, "servers/settings.html", context) + + +def _get_server_or_404(request, server_id: int) -> Server: + try: + server = Server.objects.get(id=server_id) + except Server.DoesNotExist: + raise Http404("Server not found") + if "view_server" not in get_perms(request.user, server): + raise Http404("Server not found") + return server + + +def _load_account_context(request, server: Server): + 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 + ) + 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 + diff --git a/app/keywarden/asgi.py b/app/keywarden/asgi.py index e69de29..bcd2d1f 100644 --- a/app/keywarden/asgi.py +++ b/app/keywarden/asgi.py @@ -0,0 +1,19 @@ +import os + +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from django.core.asgi import get_asgi_application + +from . import routing + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "keywarden.settings.dev") + +django_app = get_asgi_application() + +application = ProtocolTypeRouter( + { + "http": django_app, + "websocket": AuthMiddlewareStack(URLRouter(routing.websocket_urlpatterns)), + } +) diff --git a/app/keywarden/routing.py b/app/keywarden/routing.py new file mode 100644 index 0000000..b493a46 --- /dev/null +++ b/app/keywarden/routing.py @@ -0,0 +1,7 @@ +from django.urls import re_path + +from apps.servers.consumers import ShellConsumer + +websocket_urlpatterns = [ + re_path(r"^ws/servers/(?P\\d+)/shell/$", ShellConsumer.as_asgi()), +] diff --git a/app/keywarden/settings/base.py b/app/keywarden/settings/base.py index b3c0549..93db7d0 100644 --- a/app/keywarden/settings/base.py +++ b/app/keywarden/settings/base.py @@ -34,6 +34,7 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "channels", "guardian", "rest_framework", "apps.audit", @@ -93,6 +94,10 @@ CACHES = { } } +CHANNEL_LAYERS = { + "default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}, +} + SESSION_ENGINE = "django.contrib.sessions.backends.cache" SESSION_CACHE_ALIAS = "default" diff --git a/app/scripts/daphne.sh b/app/scripts/daphne.sh new file mode 100644 index 0000000..985a945 --- /dev/null +++ b/app/scripts/daphne.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu + +exec daphne -b 0.0.0.0 -p 8001 keywarden.asgi:application diff --git a/app/templates/base.html b/app/templates/base.html index ca631c4..3d18ede 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -12,38 +12,43 @@ {% tailwind_css %} + {% block extra_head %}{% endblock %}
-
-
-
- - Keywarden logo - Keywarden - - + {% if not is_popout %} +
+
+
+ + Keywarden logo + Keywarden + + +
-
-
+ + {% endif %}
{% block content %}{% endblock %}
- + {% if not is_popout %} + + {% endif %}
diff --git a/nginx/configs/nginx.conf.template b/nginx/configs/nginx.conf.template index a28ca8f..c5ac481 100644 --- a/nginx/configs/nginx.conf.template +++ b/nginx/configs/nginx.conf.template @@ -56,6 +56,19 @@ http { client_max_body_size 50M; + location /ws/ { + proxy_pass http://127.0.0.1:8001; + include options-https-headers.conf; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 1h; + } + location / { proxy_pass http://127.0.0.1:8000; include options-https-headers.conf; @@ -63,6 +76,10 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 1h; } } diff --git a/requirements.txt b/requirements.txt index 9514b97..c4530ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,8 @@ mozilla-django-oidc>=5.0.2 django-unfold>=0.76.0 django-tailwind==4.4.0 django-guardian>=3.2.0 +channels>=4.0.0 +daphne>=4.0.0 argon2-cffi>=25.1.0 psycopg2-binary>=2.9.11 gunicorn>=24.1.0 diff --git a/supervisor/supervisord.conf b/supervisor/supervisord.conf index 7a891b9..336a68d 100644 --- a/supervisor/supervisord.conf +++ b/supervisor/supervisord.conf @@ -17,6 +17,20 @@ stopsignal=TERM stopasgroup=true killasgroup=true +[program:daphne] +command=/app/scripts/daphne.sh +directory=/app +user=djangouser +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +stopsignal=TERM +stopasgroup=true +killasgroup=true + [program:nginx] command=/usr/sbin/nginx -g "daemon off;" autostart=true