ASGI via Daphne for websockets, WSGI via Gunicorn. Implemented xterm.js for shell proxy to target servers.
This commit is contained in:
@@ -56,7 +56,7 @@ COPY nginx/configs/options-* /etc/nginx/
|
|||||||
COPY supervisor/supervisord.conf /etc/supervisor/supervisord.conf
|
COPY supervisor/supervisord.conf /etc/supervisor/supervisord.conf
|
||||||
|
|
||||||
RUN python manage.py collectstatic --noinput
|
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
|
# 5. Create users for services
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ func writeCAKeyIfChanged(key string) (bool, error) {
|
|||||||
|
|
||||||
func ensureSSHDConfig() (bool, error) {
|
func ensureSSHDConfig() (bool, error) {
|
||||||
content := fmt.Sprintf(
|
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,
|
userCAPath,
|
||||||
keywardenGroup,
|
keywardenGroup,
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
197
app/apps/servers/consumers.py
Normal file
197
app/apps/servers/consumers.py
Normal file
@@ -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
|
||||||
19
app/apps/servers/migrations/0005_server_shell_permission.py
Normal file
19
app/apps/servers/migrations/0005_server_shell_permission.py
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -35,6 +35,9 @@ class Server(models.Model):
|
|||||||
ordering = ["display_name", "hostname", "ipv4", "ipv6"]
|
ordering = ["display_name", "hostname", "ipv4", "ipv6"]
|
||||||
verbose_name = "Server"
|
verbose_name = "Server"
|
||||||
verbose_name_plural = "Servers"
|
verbose_name_plural = "Servers"
|
||||||
|
permissions = [
|
||||||
|
("shell_server", "Can access server shell"),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
primary = self.hostname or self.ipv4 or self.ipv6 or "unassigned"
|
primary = self.hostname or self.ipv4 or self.ipv6 or "unassigned"
|
||||||
|
|||||||
23
app/apps/servers/permissions.py
Normal file
23
app/apps/servers/permissions.py
Normal file
@@ -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()
|
||||||
|
)
|
||||||
43
app/apps/servers/templates/servers/_header.html
Normal file
43
app/apps/servers/templates/servers/_header.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600 text-white text-xl font-semibold">
|
||||||
|
{{ server.initial }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">{{ server.display_name }}</h1>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'servers:dashboard' %}" class="text-sm font-semibold text-purple-700 hover:text-purple-800">Back to servers</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex flex-wrap gap-2 border-b border-gray-200 pb-2 text-sm font-semibold text-gray-500">
|
||||||
|
<a
|
||||||
|
href="{% url 'servers:detail' server.id %}"
|
||||||
|
class="{% if active_tab == 'details' %}rounded-full bg-purple-50 px-3 py-1 text-purple-700{% else %}rounded-full bg-gray-100 px-3 py-1 text-gray-500 hover:text-gray-700{% endif %}"
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="{% url 'servers:audit' server.id %}"
|
||||||
|
class="{% if active_tab == 'audit' %}rounded-full bg-purple-50 px-3 py-1 text-purple-700{% else %}rounded-full bg-gray-100 px-3 py-1 text-gray-500 hover:text-gray-700{% endif %}"
|
||||||
|
>
|
||||||
|
Audit
|
||||||
|
</a>
|
||||||
|
{% if can_shell %}
|
||||||
|
<a
|
||||||
|
href="{% url 'servers:shell' server.id %}"
|
||||||
|
class="{% if active_tab == 'shell' %}rounded-full bg-purple-50 px-3 py-1 text-purple-700{% else %}rounded-full bg-gray-100 px-3 py-1 text-gray-500 hover:text-gray-700{% endif %}"
|
||||||
|
>
|
||||||
|
Shell
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a
|
||||||
|
href="{% url 'servers:settings' server.id %}"
|
||||||
|
class="{% if active_tab == 'settings' %}rounded-full bg-purple-50 px-3 py-1 text-purple-700{% else %}rounded-full bg-gray-100 px-3 py-1 text-gray-500 hover:text-gray-700{% endif %}"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
29
app/apps/servers/templates/servers/audit.html
Normal file
29
app/apps/servers/templates/servers/audit.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Audit • {{ server.display_name }} • Keywarden{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<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 items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Audit logs</h2>
|
||||||
|
<span class="text-xs font-semibold text-gray-500">Placeholder</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-600">
|
||||||
|
Logs will appear here once collection is enabled for this server.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Metrics</h2>
|
||||||
|
<span class="text-xs font-semibold text-gray-500">Placeholder</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-600">
|
||||||
|
Metrics will appear here once collection is enabled for this server.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,27 +4,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
{% include "servers/_header.html" %}
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600 text-white text-xl font-semibold">
|
|
||||||
{{ server.initial }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">{{ server.display_name }}</h1>
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="{% url 'servers:dashboard' %}" class="text-sm font-semibold text-purple-700 hover:text-purple-800">Back to servers</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="flex flex-wrap gap-2 border-b border-gray-200 pb-2 text-sm font-semibold text-gray-500">
|
|
||||||
<span class="rounded-full bg-purple-50 px-3 py-1 text-purple-700">Details</span>
|
|
||||||
<span class="rounded-full bg-gray-100 px-3 py-1 text-gray-500">Audit</span>
|
|
||||||
<span class="rounded-full bg-gray-100 px-3 py-1 text-gray-500">Shell</span>
|
|
||||||
<span class="rounded-full bg-gray-100 px-3 py-1 text-gray-500">Settings</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<section class="grid gap-4 lg:grid-cols-3">
|
<section class="grid gap-4 lg:grid-cols-3">
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
@@ -58,6 +38,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<dt>Account status</dt>
|
||||||
|
<dd class="font-medium text-gray-900">
|
||||||
|
{% if account_present is None %}
|
||||||
|
Unknown
|
||||||
|
{% elif account_present %}
|
||||||
|
Present
|
||||||
|
{% else %}
|
||||||
|
Not on server
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<dt>Certificate</dt>
|
<dt>Certificate</dt>
|
||||||
<dd class="font-medium text-gray-900">
|
<dd class="font-medium text-gray-900">
|
||||||
@@ -121,16 +113,6 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Logs</h2>
|
|
||||||
<span class="text-xs font-semibold text-gray-500">Placeholder</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-600">
|
|
||||||
Logs will appear here once collection is enabled for this server.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
19
app/apps/servers/templates/servers/settings.html
Normal file
19
app/apps/servers/templates/servers/settings.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Settings • {{ server.display_name }} • Keywarden{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<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 items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Settings</h2>
|
||||||
|
<span class="text-xs font-semibold text-gray-500">Placeholder</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-600">
|
||||||
|
Settings will appear here as server options are added.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
271
app/apps/servers/templates/servers/shell.html
Normal file
271
app/apps/servers/templates/servers/shell.html
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% 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">
|
||||||
|
{% 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>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<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 %}
|
||||||
|
<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"
|
||||||
|
id="shell-popout"
|
||||||
|
data-popout-url="{% url 'servers:shell' server.id %}?popout=1"
|
||||||
|
>
|
||||||
|
Pop out terminal
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm@5.4.0/lib/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");
|
||||||
|
if (termContainer && window.Terminal) {
|
||||||
|
var term = new window.Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
|
fontSize: 13,
|
||||||
|
theme: {
|
||||||
|
background: "#0b0f12",
|
||||||
|
foreground: "#d1d5db",
|
||||||
|
cursor: "#a855f7"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
term.open(termContainer);
|
||||||
|
term.write("Connecting...\r\n");
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
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");
|
||||||
|
};
|
||||||
|
|
||||||
|
term.onData(function (data) {
|
||||||
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -7,4 +7,7 @@ app_name = "servers"
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.dashboard, name="dashboard"),
|
path("", views.dashboard, name="dashboard"),
|
||||||
path("<int:server_id>/", views.detail, name="detail"),
|
path("<int:server_id>/", views.detail, name="detail"),
|
||||||
|
path("<int:server_id>/audit/", views.audit, name="audit"),
|
||||||
|
path("<int:server_id>/shell/", views.shell, name="shell"),
|
||||||
|
path("<int:server_id>/settings/", views.settings, name="settings"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ from django.utils import timezone
|
|||||||
from guardian.shortcuts import get_objects_for_user, get_perms
|
from guardian.shortcuts import get_objects_for_user, get_perms
|
||||||
|
|
||||||
from apps.access.models import AccessRequest
|
from apps.access.models import AccessRequest
|
||||||
|
from apps.keys.utils import render_system_username
|
||||||
from apps.keys.models import SSHKey
|
from apps.keys.models import SSHKey
|
||||||
from apps.servers.models import Server, ServerAccount
|
from apps.servers.models import Server, ServerAccount
|
||||||
|
from apps.servers.permissions import user_can_shell
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url="/accounts/login/")
|
@login_required(login_url="/accounts/login/")
|
||||||
@@ -60,12 +62,8 @@ def dashboard(request):
|
|||||||
@login_required(login_url="/accounts/login/")
|
@login_required(login_url="/accounts/login/")
|
||||||
def detail(request, server_id: int):
|
def detail(request, server_id: int):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
try:
|
server = _get_server_or_404(request, server_id)
|
||||||
server = Server.objects.get(id=server_id)
|
can_shell = user_can_shell(request.user, server, now)
|
||||||
except Server.DoesNotExist:
|
|
||||||
raise Http404("Server not found")
|
|
||||||
if "view_server" not in get_perms(request.user, server):
|
|
||||||
raise Http404("Server not found")
|
|
||||||
|
|
||||||
access = (
|
access = (
|
||||||
AccessRequest.objects.filter(
|
AccessRequest.objects.filter(
|
||||||
@@ -78,17 +76,90 @@ def detail(request, server_id: int):
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
account = ServerAccount.objects.filter(server=server, user=request.user).first()
|
account, system_username, certificate_key_id = _load_account_context(request, server)
|
||||||
active_key = (
|
|
||||||
SSHKey.objects.filter(user=request.user, is_active=True).order_by("-created_at").first()
|
|
||||||
)
|
|
||||||
context = {
|
context = {
|
||||||
"server": server,
|
"server": server,
|
||||||
"expires_at": access.expires_at if access else None,
|
"expires_at": access.expires_at if access else None,
|
||||||
"last_accessed": None,
|
"last_accessed": None,
|
||||||
"account_present": account.is_present if account else None,
|
"account_present": account.is_present if account else None,
|
||||||
"account_synced_at": account.last_synced_at if account else None,
|
"account_synced_at": account.last_synced_at if account else None,
|
||||||
"system_username": account.system_username if account else None,
|
"system_username": system_username,
|
||||||
"certificate_key_id": active_key.id if active_key else None,
|
"certificate_key_id": certificate_key_id,
|
||||||
|
"active_tab": "details",
|
||||||
|
"can_shell": can_shell,
|
||||||
}
|
}
|
||||||
return render(request, "servers/detail.html", context)
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
7
app/keywarden/routing.py
Normal file
7
app/keywarden/routing.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
|
from apps.servers.consumers import ShellConsumer
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r"^ws/servers/(?P<server_id>\\d+)/shell/$", ShellConsumer.as_asgi()),
|
||||||
|
]
|
||||||
@@ -34,6 +34,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
|
"channels",
|
||||||
"guardian",
|
"guardian",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"apps.audit",
|
"apps.audit",
|
||||||
@@ -93,6 +94,10 @@ CACHES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CHANNEL_LAYERS = {
|
||||||
|
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
|
||||||
|
}
|
||||||
|
|
||||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||||
SESSION_CACHE_ALIAS = "default"
|
SESSION_CACHE_ALIAS = "default"
|
||||||
|
|
||||||
|
|||||||
4
app/scripts/daphne.sh
Normal file
4
app/scripts/daphne.sh
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
exec daphne -b 0.0.0.0 -p 8001 keywarden.asgi:application
|
||||||
@@ -12,9 +12,11 @@
|
|||||||
<meta property="og:title" content="Keywarden">
|
<meta property="og:title" content="Keywarden">
|
||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
{% tailwind_css %}
|
{% tailwind_css %}
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-gray-50 text-gray-900 antialiased">
|
<body class="min-h-screen bg-gray-50 text-gray-900 antialiased">
|
||||||
<div class="min-h-screen flex flex-col">
|
<div class="min-h-screen flex flex-col">
|
||||||
|
{% if not is_popout %}
|
||||||
<header class="border-b border-gray-200 bg-white">
|
<header class="border-b border-gray-200 bg-white">
|
||||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex h-20 items-center justify-between">
|
<div class="flex h-20 items-center justify-between">
|
||||||
@@ -34,16 +36,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<main class="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8 flex-1 w-full">
|
<main class="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8 flex-1 w-full">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{% if not is_popout %}
|
||||||
<footer class="border-t border-gray-200 bg-white">
|
<footer class="border-t border-gray-200 bg-white">
|
||||||
<div class="mx-auto max-w-7xl px-4 py-6 text-sm text-gray-500 sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl px-4 py-6 text-sm text-gray-500 sm:px-6 lg:px-8">
|
||||||
<a class="underline text-blue-600 hover:text-blue-800 visited:text-purple-600" href="https://git.ntbx.io/boris/keywarden">Keywarden</a> | <a class="underline text-blue-600 hover:text-blue-800 visited:text-purple-600" href="https://ntbx.io">George Wilkinson</a> (2025)
|
<a class="underline text-blue-600 hover:text-blue-800 visited:text-purple-600" href="https://git.ntbx.io/boris/keywarden">Keywarden</a> | <a class="underline text-blue-600 hover:text-blue-800 visited:text-purple-600" href="https://ntbx.io">George Wilkinson</a> (2025)
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -56,6 +56,19 @@ http {
|
|||||||
|
|
||||||
client_max_body_size 50M;
|
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 / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8000;
|
proxy_pass http://127.0.0.1:8000;
|
||||||
include options-https-headers.conf;
|
include options-https-headers.conf;
|
||||||
@@ -63,6 +76,10 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $forwarded_for;
|
proxy_set_header X-Forwarded-For $forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ mozilla-django-oidc>=5.0.2
|
|||||||
django-unfold>=0.76.0
|
django-unfold>=0.76.0
|
||||||
django-tailwind==4.4.0
|
django-tailwind==4.4.0
|
||||||
django-guardian>=3.2.0
|
django-guardian>=3.2.0
|
||||||
|
channels>=4.0.0
|
||||||
|
daphne>=4.0.0
|
||||||
argon2-cffi>=25.1.0
|
argon2-cffi>=25.1.0
|
||||||
psycopg2-binary>=2.9.11
|
psycopg2-binary>=2.9.11
|
||||||
gunicorn>=24.1.0
|
gunicorn>=24.1.0
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ stopsignal=TERM
|
|||||||
stopasgroup=true
|
stopasgroup=true
|
||||||
killasgroup=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]
|
[program:nginx]
|
||||||
command=/usr/sbin/nginx -g "daemon off;"
|
command=/usr/sbin/nginx -g "daemon off;"
|
||||||
autostart=true
|
autostart=true
|
||||||
|
|||||||
Reference in New Issue
Block a user