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

@@ -1,6 +1,6 @@
{ {
"server_url": "https://keywarden.example.com", "server_url": "https://keywarden.dev.ntbx.io/api/v1",
"server_id": "", "server_id": "4",
"sync_interval_seconds": 30, "sync_interval_seconds": 30,
"log_batch_size": 500, "log_batch_size": 500,
"state_dir": "/var/lib/keywarden-agent", "state_dir": "/var/lib/keywarden-agent",
@@ -11,4 +11,4 @@
"create_home": true, "create_home": true,
"lock_on_revoke": true "lock_on_revoke": true
} }
} }

Binary file not shown.

View File

@@ -8,7 +8,7 @@
<h1 class="mb-6 text-xl font-semibold tracking-tight text-gray-900">Sign in</h1> <h1 class="mb-6 text-xl font-semibold tracking-tight text-gray-900">Sign in</h1>
<form method="post" class="space-y-4"> <form method="post" class="space-y-4">
{% csrf_token %} {% 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"> <div class="space-y-1.5">
<label class="block text-sm font-medium text-gray-700">Username</label> <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"> <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>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -59,12 +59,11 @@ class AgentCertificateAuthorityAdmin(admin.ModelAdmin):
list_display = ("name", "is_active", "created_at", "revoked_at") list_display = ("name", "is_active", "created_at", "revoked_at")
list_filter = ("is_active", "created_at", "revoked_at") list_filter = ("is_active", "created_at", "revoked_at")
search_fields = ("name", "fingerprint") 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 = ( fields = (
"name", "name",
"is_active", "is_active",
"cert_pem", "cert_pem",
"key_pem",
"fingerprint", "fingerprint",
"serial", "serial",
"created_by", "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 datetime import datetime, timedelta
from typing import List, Optional from typing import List, Optional
@@ -8,10 +6,11 @@ from cryptography.hazmat.primitives import hashes, serialization
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError 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.http import HttpRequest
from django.utils import timezone 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 ninja.errors import HttpError
from pydantic import Field from pydantic import Field
@@ -78,7 +77,8 @@ def build_router() -> Router:
router = Router() router = Router()
@router.post("/enroll", response=AgentEnrollOut, auth=None) @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.""" """Enroll a server agent using a one-time token."""
token_value = (payload.token or "").strip() token_value = (payload.token or "").strip()
if not token_value: if not token_value:
@@ -100,16 +100,19 @@ def build_router() -> Router:
except ValidationError: except ValidationError:
hostname = None 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()) csr = _load_csr((payload.csr_pem or "").strip())
cert_pem, ca_pem, fingerprint, serial = _issue_client_cert(csr, host, server.id) try:
server.agent_enrolled_at = timezone.now() with transaction.atomic():
server.agent_cert_fingerprint = fingerprint server = Server.objects.create(display_name=display_name, hostname=hostname)
server.agent_cert_serial = serial token.mark_used(server)
server.save(update_fields=["agent_enrolled_at", "agent_cert_fingerprint", "agent_cert_serial"]) 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( return AgentEnrollOut(
server_id=str(server.id), server_id=str(server.id),
@@ -153,10 +156,10 @@ def build_router() -> Router:
for key in keys for key in keys
] ]
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut) @router.post("/servers/{server_id}/sync-report", response=SyncReportOut, auth=None)
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn): @csrf_exempt
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn = Body(...)):
"""Record an agent sync report for a server (admin or operator).""" """Record an agent sync report for a server (admin or operator)."""
require_perms(request, "servers.view_server", "telemetry.add_telemetryevent")
try: try:
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
@@ -176,7 +179,8 @@ def build_router() -> Router:
return SyncReportOut(status="ok") return SyncReportOut(status="ok")
@router.post("/servers/{server_id}/logs", response=LogIngestOut, auth=None) @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).""" """Accept log batches from agents (mTLS required at the edge)."""
try: try:
Server.objects.get(id=server_id) Server.objects.get(id=server_id)

View File

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

View File

@@ -8,10 +8,11 @@ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("oidc/", include("mozilla_django_oidc.urls")), path("oidc/", include("mozilla_django_oidc.urls")),
path("accounts/", include("apps.accounts.urls")), path("accounts/", include("apps.accounts.urls")),
path("servers/", include("apps.servers.urls")),
# API # API
path("api/", ninja_api.urls), path("api/", ninja_api.urls),
path("api/v1/", ninja_api_v1.urls), path("api/v1/", ninja_api_v1.urls),
path("api/auth/jwt/create/", TokenObtainPairView.as_view(), name="jwt-create"), path("api/auth/jwt/create/", TokenObtainPairView.as_view(), name="jwt-create"),
path("api/auth/jwt/refresh/", TokenRefreshView.as_view(), name="jwt-refresh"), 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> </a>
<nav class="flex items-center gap-4"> <nav class="flex items-center gap-4">
{% if request.user.is_authenticated %} {% 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: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> <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 %} {% else %}
@@ -42,4 +43,3 @@
</body> </body>
</html> </html>