Linux agent functional. Added new client-facing server panel. Removed deferred pydantic annotations.
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
Binary file not shown.
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
67
app/apps/servers/templates/servers/dashboard.html
Normal file
67
app/apps/servers/templates/servers/dashboard.html
Normal 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 %}
|
||||||
78
app/apps/servers/templates/servers/detail.html
Normal file
78
app/apps/servers/templates/servers/detail.html
Normal 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
10
app/apps/servers/urls.py
Normal 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
67
app/apps/servers/views.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user