Linux agent functional. Added new client-facing server panel. Removed deferred pydantic annotations.

This commit is contained in:
2026-01-25 23:08:40 +00:00
parent 4885622d6a
commit b95084ddc3
12 changed files with 253 additions and 28 deletions

View File

@@ -8,7 +8,7 @@
<h1 class="mb-6 text-xl font-semibold tracking-tight text-gray-900">Sign in</h1>
<form method="post" class="space-y-4">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'accounts:profile' %}">
<input type="hidden" name="next" value="{% url 'servers:dashboard' %}">
<div class="space-y-1.5">
<label class="block text-sm font-medium text-gray-700">Username</label>
<input type="text" name="username" autocomplete="username" required class="block w-full rounded-md border-gray-300 shadow-sm focus:border-purple-600 focus:ring-purple-600">
@@ -35,4 +35,3 @@
</div>
</div>
{% endblock %}

View File

@@ -59,12 +59,11 @@ class AgentCertificateAuthorityAdmin(admin.ModelAdmin):
list_display = ("name", "is_active", "created_at", "revoked_at")
list_filter = ("is_active", "created_at", "revoked_at")
search_fields = ("name", "fingerprint")
readonly_fields = ("fingerprint", "serial", "created_at", "revoked_at", "created_by")
readonly_fields = ("cert_pem", "fingerprint", "serial", "created_at", "revoked_at", "created_by")
fields = (
"name",
"is_active",
"cert_pem",
"key_pem",
"fingerprint",
"serial",
"created_by",

View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}Servers • Keywarden{% endblock %}
{% block content %}
<div class="space-y-8">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Servers</h1>
<p class="mt-2 text-sm text-gray-600">Your active server access and recent activity at a glance.</p>
</div>
{% if servers %}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{% for item in servers %}
<article class="group relative overflow-hidden rounded-2xl border border-gray-200 bg-white p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-600 text-white font-semibold">
{{ item.server.initial }}
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900">{{ item.server.display_name }}</h2>
<p class="text-xs text-gray-500">
{{ item.server.hostname|default:item.server.ipv4|default:item.server.ipv6|default:"Unassigned" }}
</p>
</div>
</div>
<span class="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700">Active</span>
</div>
<dl class="mt-4 space-y-2 text-sm text-gray-600">
<div class="flex items-center justify-between">
<dt>Access until</dt>
<dd class="font-medium text-gray-900">
{% if item.expires_at %}
{{ item.expires_at|date:"M j, Y H:i" }}
{% else %}
No expiry
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between">
<dt>Last accessed</dt>
<dd class="font-medium text-gray-900">
{% if item.last_accessed %}
{{ item.last_accessed|date:"M j, Y H:i" }}
{% else %}
{% endif %}
</dd>
</div>
</dl>
<div class="mt-4 border-t border-gray-100 pt-3 text-xs text-gray-500">
<a href="{% url 'servers:detail' item.server.id %}" class="font-semibold text-purple-700 hover:text-purple-800">View details and logs</a>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="rounded-xl border border-dashed border-gray-300 bg-white p-10 text-center">
<h2 class="text-lg font-semibold text-gray-900">No server access yet</h2>
<p class="mt-2 text-sm text-gray-600">Request access to a server to see it here.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}{{ server.display_name }} • Keywarden{% endblock %}
{% block content %}
<div class="space-y-8">
<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>
<section class="grid gap-4 lg:grid-cols-3">
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm lg:col-span-2">
<h2 class="text-lg font-semibold text-gray-900">Access</h2>
<dl class="mt-4 space-y-3 text-sm text-gray-600">
<div class="flex items-center justify-between">
<dt>Access until</dt>
<dd class="font-medium text-gray-900">
{% if expires_at %}
{{ expires_at|date:"M j, Y H:i" }}
{% else %}
No expiry
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between">
<dt>Last accessed</dt>
<dd class="font-medium text-gray-900">
{% if last_accessed %}
{{ last_accessed|date:"M j, Y H:i" }}
{% else %}
{% endif %}
</dd>
</div>
</dl>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">Server details</h2>
<dl class="mt-4 space-y-3 text-sm text-gray-600">
<div class="flex items-center justify-between">
<dt>Hostname</dt>
<dd class="font-medium text-gray-900">{{ server.hostname|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between">
<dt>IPv4</dt>
<dd class="font-medium text-gray-900">{{ server.ipv4|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between">
<dt>IPv6</dt>
<dd class="font-medium text-gray-900">{{ server.ipv6|default:"—" }}</dd>
</div>
</dl>
</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">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>
{% endblock %}

10
app/apps/servers/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = "servers"
urlpatterns = [
path("", views.dashboard, name="dashboard"),
path("<int:server_id>/", views.detail, name="detail"),
]

67
app/apps/servers/views.py Normal file
View File

@@ -0,0 +1,67 @@
from __future__ import annotations
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.http import Http404
from django.shortcuts import render
from django.utils import timezone
from apps.access.models import AccessRequest
@login_required(login_url="/accounts/login/")
def dashboard(request):
now = timezone.now()
access_qs = (
AccessRequest.objects.select_related("server")
.filter(
requester=request.user,
status=AccessRequest.Status.APPROVED,
)
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
.order_by("-requested_at")
)
seen = set()
servers = []
for access in access_qs:
if access.server_id in seen:
continue
seen.add(access.server_id)
servers.append(
{
"server": access.server,
"expires_at": access.expires_at,
"last_accessed": None,
}
)
context = {
"servers": servers,
}
return render(request, "servers/dashboard.html", context)
@login_required(login_url="/accounts/login/")
def detail(request, server_id: int):
now = timezone.now()
access = (
AccessRequest.objects.select_related("server")
.filter(
requester=request.user,
server_id=server_id,
status=AccessRequest.Status.APPROVED,
)
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
.order_by("-requested_at")
.first()
)
if not access:
raise Http404("Server not found")
context = {
"server": access.server,
"expires_at": access.expires_at,
"last_accessed": None,
}
return render(request, "servers/detail.html", context)

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import List, Optional
@@ -8,10 +6,11 @@ from cryptography.hazmat.primitives import hashes, serialization
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db import IntegrityError, models, transaction
from django.http import HttpRequest
from django.utils import timezone
from ninja import Router, Schema
from django.views.decorators.csrf import csrf_exempt
from ninja import Body, Router, Schema
from ninja.errors import HttpError
from pydantic import Field
@@ -78,7 +77,8 @@ def build_router() -> Router:
router = Router()
@router.post("/enroll", response=AgentEnrollOut, auth=None)
def enroll_agent(request: HttpRequest, payload: AgentEnrollIn):
@csrf_exempt
def enroll_agent(request: HttpRequest, payload: AgentEnrollIn = Body(...)):
"""Enroll a server agent using a one-time token."""
token_value = (payload.token or "").strip()
if not token_value:
@@ -100,16 +100,19 @@ def build_router() -> Router:
except ValidationError:
hostname = None
server = Server.objects.create(display_name=display_name, hostname=hostname)
token.mark_used(server)
token.save(update_fields=["used_at", "server"])
csr = _load_csr((payload.csr_pem or "").strip())
cert_pem, ca_pem, fingerprint, serial = _issue_client_cert(csr, host, server.id)
server.agent_enrolled_at = timezone.now()
server.agent_cert_fingerprint = fingerprint
server.agent_cert_serial = serial
server.save(update_fields=["agent_enrolled_at", "agent_cert_fingerprint", "agent_cert_serial"])
try:
with transaction.atomic():
server = Server.objects.create(display_name=display_name, hostname=hostname)
token.mark_used(server)
token.save(update_fields=["used_at", "server"])
cert_pem, ca_pem, fingerprint, serial = _issue_client_cert(csr, host, server.id)
server.agent_enrolled_at = timezone.now()
server.agent_cert_fingerprint = fingerprint
server.agent_cert_serial = serial
server.save(update_fields=["agent_enrolled_at", "agent_cert_fingerprint", "agent_cert_serial"])
except IntegrityError:
raise HttpError(409, "Server already enrolled")
return AgentEnrollOut(
server_id=str(server.id),
@@ -153,10 +156,10 @@ def build_router() -> Router:
for key in keys
]
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut)
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn):
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut, auth=None)
@csrf_exempt
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn = Body(...)):
"""Record an agent sync report for a server (admin or operator)."""
require_perms(request, "servers.view_server", "telemetry.add_telemetryevent")
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
@@ -176,7 +179,8 @@ def build_router() -> Router:
return SyncReportOut(status="ok")
@router.post("/servers/{server_id}/logs", response=LogIngestOut, auth=None)
def ingest_logs(request: HttpRequest, server_id: int, payload: List[LogEventIn]):
@csrf_exempt
def ingest_logs(request: HttpRequest, server_id: int, payload: List[LogEventIn] = Body(...)):
"""Accept log batches from agents (mTLS required at the edge)."""
try:
Server.objects.get(id=server_id)

View File

@@ -226,7 +226,7 @@ else:
]
LOGIN_URL = "/accounts/login/"
LOGOUT_URL = "/oidc/logout/"
LOGIN_REDIRECT_URL = "/"
LOGIN_REDIRECT_URL = "/servers/"
LOGOUT_REDIRECT_URL = "/"
ANONYMOUS_USER_NAME = None

View File

@@ -8,10 +8,11 @@ urlpatterns = [
path("admin/", admin.site.urls),
path("oidc/", include("mozilla_django_oidc.urls")),
path("accounts/", include("apps.accounts.urls")),
path("servers/", include("apps.servers.urls")),
# API
path("api/", ninja_api.urls),
path("api/v1/", ninja_api_v1.urls),
path("api/auth/jwt/create/", TokenObtainPairView.as_view(), name="jwt-create"),
path("api/auth/jwt/refresh/", TokenRefreshView.as_view(), name="jwt-refresh"),
path("", RedirectView.as_view(pattern_name="accounts:login", permanent=False)),
path("", RedirectView.as_view(pattern_name="servers:dashboard", permanent=False)),
]

View File

@@ -19,6 +19,7 @@
</a>
<nav class="flex items-center gap-4">
{% if request.user.is_authenticated %}
<a href="{% url 'servers:dashboard' %}" class="text-sm font-medium text-gray-700 hover:text-purple-700">Servers</a>
<a href="{% url 'accounts:profile' %}" class="text-sm font-medium text-gray-700 hover:text-purple-700">Profile</a>
<a href="{% url 'accounts:logout' %}" class="inline-flex items-center rounded-md bg-purple-600 px-3 py-1.5 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600">Logout</a>
{% else %}
@@ -42,4 +43,3 @@
</body>
</html>