Initial linux agent and api functionality for enrolling servers

This commit is contained in:
2026-01-25 22:24:20 +00:00
parent 66ffa3d3fb
commit 4885622d6a
23 changed files with 1351 additions and 50 deletions

View File

@@ -1,17 +1,27 @@
from django.contrib import admin
from guardian.admin import GuardedModelAdmin
from django.utils.html import format_html
from .models import Server
from guardian.admin import GuardedModelAdmin
from .models import AgentCertificateAuthority, EnrollmentToken, Server
@admin.register(Server)
class ServerAdmin(GuardedModelAdmin):
list_display = ("avatar", "display_name", "hostname", "ipv4", "ipv6", "created_at")
list_display = ("avatar", "display_name", "hostname", "ipv4", "ipv6", "agent_enrolled_at", "created_at")
list_display_links = ("display_name",)
search_fields = ("display_name", "hostname", "ipv4", "ipv6")
list_filter = ("created_at",)
readonly_fields = ("created_at", "updated_at")
fields = ("display_name", "hostname", "ipv4", "ipv6", "image", "created_at", "updated_at")
readonly_fields = ("created_at", "updated_at", "agent_enrolled_at")
fields = (
"display_name",
"hostname",
"ipv4",
"ipv6",
"image",
"agent_enrolled_at",
"created_at",
"updated_at",
)
def avatar(self, obj: Server):
if obj.image_url:
@@ -27,3 +37,52 @@ class ServerAdmin(GuardedModelAdmin):
)
avatar.short_description = ""
@admin.register(EnrollmentToken)
class EnrollmentTokenAdmin(admin.ModelAdmin):
list_display = ("token", "created_at", "expires_at", "used_at", "server")
list_filter = ("created_at", "used_at")
search_fields = ("token", "server__display_name", "server__hostname")
readonly_fields = ("token", "created_at", "used_at", "server", "created_by")
fields = ("token", "expires_at", "created_by", "created_at", "used_at", "server")
def save_model(self, request, obj, form, change) -> None:
if not obj.pk:
obj.ensure_token()
if request.user and request.user.is_authenticated and not obj.created_by_id:
obj.created_by = request.user
super().save_model(request, obj, form, change)
@admin.register(AgentCertificateAuthority)
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")
fields = (
"name",
"is_active",
"cert_pem",
"key_pem",
"fingerprint",
"serial",
"created_by",
"created_at",
"revoked_at",
)
actions = ["revoke_selected"]
def save_model(self, request, obj, form, change) -> None:
if request.user and request.user.is_authenticated and not obj.created_by_id:
obj.created_by = request.user
obj.ensure_material()
if obj.is_active:
AgentCertificateAuthority.objects.exclude(pk=obj.pk).update(is_active=False)
super().save_model(request, obj, form, change)
@admin.action(description="Revoke selected CAs")
def revoke_selected(self, request, queryset):
for ca in queryset:
ca.revoke()
ca.save(update_fields=["is_active", "revoked_at"])

View File

@@ -0,0 +1,73 @@
from django.conf import settings
from django.db import migrations, models
import django.utils.timezone
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("servers", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="server",
name="agent_cert_fingerprint",
field=models.CharField(blank=True, max_length=128, null=True),
),
migrations.AddField(
model_name="server",
name="agent_cert_serial",
field=models.CharField(blank=True, max_length=64, null=True),
),
migrations.AddField(
model_name="server",
name="agent_enrolled_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.CreateModel(
name="EnrollmentToken",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("token", models.CharField(max_length=128, unique=True)),
("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
("expires_at", models.DateTimeField(blank=True, null=True)),
("used_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="server_enrollment_tokens",
to=settings.AUTH_USER_MODEL,
),
),
(
"server",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="enrollment_tokens",
to="servers.server",
),
),
],
options={
"verbose_name": "Enrollment token",
"verbose_name_plural": "Enrollment tokens",
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name="enrollmenttoken",
index=models.Index(fields=["created_at"], name="servers_enroll_created_idx"),
),
migrations.AddIndex(
model_name="enrollmenttoken",
index=models.Index(fields=["used_at"], name="servers_enroll_used_idx"),
),
]

View File

@@ -0,0 +1,44 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
("servers", "0002_agent_enrollment"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="AgentCertificateAuthority",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(default="Keywarden Agent CA", max_length=128)),
("cert_pem", models.TextField()),
("key_pem", models.TextField()),
("fingerprint", models.CharField(blank=True, max_length=128)),
("serial", models.CharField(blank=True, max_length=64)),
("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
("revoked_at", models.DateTimeField(blank=True, null=True)),
("is_active", models.BooleanField(db_index=True, default=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="agent_certificate_authorities",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Agent certificate authority",
"verbose_name_plural": "Agent certificate authorities",
"ordering": ["-created_at"],
},
),
]

View File

@@ -1,8 +1,16 @@
from __future__ import annotations
import secrets
from datetime import datetime, timedelta
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from django.conf import settings
from django.core.validators import RegexValidator
from django.db import models
from django.utils.text import slugify
from django.utils import timezone
hostname_validator = RegexValidator(
@@ -17,6 +25,9 @@ class Server(models.Model):
ipv4 = models.GenericIPAddressField(null=True, blank=True, protocol="IPv4", unique=True)
ipv6 = models.GenericIPAddressField(null=True, blank=True, protocol="IPv6", unique=True)
image = models.ImageField(upload_to="servers/", null=True, blank=True)
agent_enrolled_at = models.DateTimeField(null=True, blank=True)
agent_cert_fingerprint = models.CharField(max_length=128, null=True, blank=True)
agent_cert_serial = models.CharField(max_length=64, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -41,3 +52,108 @@ class Server(models.Model):
return (self.display_name or "?").strip()[:1].upper() or "?"
class EnrollmentToken(models.Model):
token = models.CharField(max_length=128, unique=True)
created_at = models.DateTimeField(default=timezone.now, editable=False)
expires_at = models.DateTimeField(null=True, blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="server_enrollment_tokens",
)
used_at = models.DateTimeField(null=True, blank=True)
server = models.ForeignKey(
Server, null=True, blank=True, on_delete=models.SET_NULL, related_name="enrollment_tokens"
)
class Meta:
verbose_name = "Enrollment token"
verbose_name_plural = "Enrollment tokens"
indexes = [
models.Index(fields=["created_at"], name="servers_enroll_created_idx"),
models.Index(fields=["used_at"], name="servers_enroll_used_idx"),
]
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.token[:8]}... ({'used' if self.used_at else 'unused'})"
def ensure_token(self) -> None:
if not self.token:
self.token = secrets.token_urlsafe(32)
def is_valid(self) -> bool:
if self.used_at:
return False
if self.expires_at and self.expires_at <= timezone.now():
return False
return True
def mark_used(self, server: Server) -> None:
self.used_at = timezone.now()
self.server = server
def save(self, *args, **kwargs):
self.ensure_token()
super().save(*args, **kwargs)
class AgentCertificateAuthority(models.Model):
name = models.CharField(max_length=128, default="Keywarden Agent CA")
cert_pem = models.TextField()
key_pem = models.TextField()
fingerprint = models.CharField(max_length=128, blank=True)
serial = models.CharField(max_length=64, blank=True)
created_at = models.DateTimeField(default=timezone.now, editable=False)
revoked_at = models.DateTimeField(null=True, blank=True)
is_active = models.BooleanField(default=True, db_index=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="agent_certificate_authorities",
)
class Meta:
verbose_name = "Agent certificate authority"
verbose_name_plural = "Agent certificate authorities"
ordering = ["-created_at"]
def __str__(self) -> str:
status = "active" if self.is_active and not self.revoked_at else "revoked"
return f"{self.name} ({status})"
def revoke(self) -> None:
self.is_active = False
self.revoked_at = timezone.now()
def ensure_material(self) -> None:
if self.cert_pem and self.key_pem:
return
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, self.name)])
now = datetime.utcnow()
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=5))
.not_valid_after(now + timedelta(days=3650))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(key, hashes.SHA256())
)
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
key_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
self.cert_pem = cert_pem
self.key_pem = key_pem
self.fingerprint = cert.fingerprint(hashes.SHA256()).hex()
self.serial = format(cert.serial_number, "x")

View File

@@ -70,7 +70,14 @@ def build_router() -> Router:
@router.get("/", response=List[AccessRequestOut])
def list_requests(request: HttpRequest, filters: AccessQuery = Query(...)):
"""List access requests for the user, or all if admin/operator."""
"""List access requests with pagination and filters.
Auth: required.
Permissions:
- If user has global `access.view_accessrequest`, returns all requests.
- Otherwise, returns only objects with `access.view_accessrequest` object permission.
Filters: status, server_id, requester_id (requester_id is honored only with global view).
"""
require_authenticated(request)
user = request.user
if _has_global_perm(request, "access.view_accessrequest"):
@@ -94,7 +101,12 @@ def build_router() -> Router:
@router.post("/", response=AccessRequestOut)
def create_request(request: HttpRequest, payload: AccessRequestCreateIn):
"""Create a new access request for a server."""
"""Create a new access request for the current user.
Auth: required.
Permissions: requires global `access.add_accessrequest`.
Side effects: grants owner object perms on the new request.
"""
require_authenticated(request)
if not request.user.has_perm("access.add_accessrequest"):
raise HttpError(403, "Forbidden")
@@ -116,7 +128,11 @@ def build_router() -> Router:
@router.get("/{request_id}", response=AccessRequestOut)
def get_request(request: HttpRequest, request_id: int):
"""Get an access request if permitted."""
"""Get a single access request by id.
Auth: required.
Permissions: requires `access.view_accessrequest` on the object.
"""
require_authenticated(request)
try:
access_request = AccessRequest.objects.get(id=request_id)
@@ -128,7 +144,15 @@ def build_router() -> Router:
@router.patch("/{request_id}", response=AccessRequestOut)
def update_request(request: HttpRequest, request_id: int, payload: AccessRequestUpdateIn):
"""Update request status or expiry (admin/operator or owner with restrictions)."""
"""Update request status or expiry.
Auth: required.
Permissions: requires `access.change_accessrequest` on the object.
Rules:
- Admin/operator (global change) can set status to approved/denied/revoked/cancelled and
update expires_at.
- Non-admin can only set status to cancelled, and only while pending.
"""
require_authenticated(request)
try:
access_request = AccessRequest.objects.get(id=request_id)
@@ -171,7 +195,11 @@ def build_router() -> Router:
@router.delete("/{request_id}", response={204: None})
def delete_request(request: HttpRequest, request_id: int):
"""Delete an access request if permitted."""
"""Delete an access request.
Auth: required.
Permissions: requires `access.delete_accessrequest` on the object.
"""
require_authenticated(request)
try:
access_request = AccessRequest.objects.get(id=request_id)

View File

@@ -1,7 +1,13 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import List, Optional
from cryptography import x509
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.http import HttpRequest
from django.utils import timezone
@@ -12,7 +18,7 @@ from pydantic import Field
from apps.core.rbac import require_perms
from apps.access.models import AccessRequest
from apps.keys.models import SSHKey
from apps.servers.models import Server
from apps.servers.models import AgentCertificateAuthority, EnrollmentToken, Server, hostname_validator
from apps.telemetry.models import TelemetryEvent
@@ -35,9 +41,82 @@ class SyncReportOut(Schema):
status: str
class AgentEnrollIn(Schema):
token: str
csr_pem: str
host: Optional[str] = None
class AgentEnrollOut(Schema):
server_id: str
client_cert_pem: str
ca_cert_pem: str
class LogEventIn(Schema):
timestamp: str
category: str
event_type: str
unit: Optional[str] = None
priority: Optional[str] = None
hostname: Optional[str] = None
username: Optional[str] = None
principal: Optional[str] = None
source_ip: Optional[str] = None
session_id: Optional[str] = None
message: Optional[str] = None
raw: Optional[str] = None
fields: Optional[dict] = None
class LogIngestOut(Schema):
status: str
accepted: int
def build_router() -> Router:
router = Router()
@router.post("/enroll", response=AgentEnrollOut, auth=None)
def enroll_agent(request: HttpRequest, payload: AgentEnrollIn):
"""Enroll a server agent using a one-time token."""
token_value = (payload.token or "").strip()
if not token_value:
raise HttpError(422, "Token required")
try:
token = EnrollmentToken.objects.get(token=token_value)
except EnrollmentToken.DoesNotExist:
raise HttpError(403, "Invalid token")
if not token.is_valid():
raise HttpError(403, "Token expired or already used")
host = (payload.host or "").strip()[:253]
display_name = host or "server"
hostname = None
if host:
try:
hostname_validator(host)
hostname = host
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"])
return AgentEnrollOut(
server_id=str(server.id),
client_cert_pem=cert_pem,
ca_cert_pem=ca_pem,
)
@router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut])
def authorized_keys(request: HttpRequest, server_id: int):
"""Return authorized public keys for a server (admin or operator)."""
@@ -96,7 +175,75 @@ 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]):
"""Accept log batches from agents (mTLS required at the edge)."""
try:
Server.objects.get(id=server_id)
except Server.DoesNotExist:
raise HttpError(404, "Server not found")
# TODO: enqueue to Valkey and persist to SQLite slices.
return LogIngestOut(status="accepted", accepted=len(payload))
return router
def _load_agent_ca() -> tuple[x509.Certificate, object, str]:
ca = (
AgentCertificateAuthority.objects.filter(is_active=True, revoked_at__isnull=True)
.order_by("-created_at")
.first()
)
if not ca:
raise HttpError(500, "Agent CA not configured")
try:
ca_cert = x509.load_pem_x509_certificate(ca.cert_pem.encode("utf-8"))
ca_key = serialization.load_pem_private_key(ca.key_pem.encode("utf-8"), password=None)
except (ValueError, TypeError):
raise HttpError(500, "Invalid agent CA material")
return ca_cert, ca_key, ca.cert_pem
def _load_csr(csr_pem: str) -> x509.CertificateSigningRequest:
try:
csr = x509.load_pem_x509_csr(csr_pem.encode("utf-8"))
except ValueError:
raise HttpError(422, "Invalid CSR")
if not csr.is_signature_valid:
raise HttpError(422, "Invalid CSR signature")
return csr
def _issue_client_cert(
csr: x509.CertificateSigningRequest, host: str | None, server_id: int
) -> tuple[str, str, str, str]:
ca_cert, ca_key, ca_pem = _load_agent_ca()
now = datetime.utcnow()
subject = csr.subject
if len(subject) == 0:
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"keywarden-agent-{server_id}")])
builder = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(ca_cert.subject)
.public_key(csr.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=5))
.not_valid_after(now + timedelta(days=settings.KEYWARDEN_AGENT_CERT_VALIDITY_DAYS))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False)
)
if host:
try:
hostname_validator(host)
builder = builder.add_extension(x509.SubjectAlternativeName([x509.DNSName(host)]), critical=False)
except ValidationError:
pass
cert = builder.sign(private_key=ca_key, algorithm=hashes.SHA256())
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
fingerprint = cert.fingerprint(hashes.SHA256()).hex()
serial = format(cert.serial_number, "x")
return cert_pem, ca_pem, fingerprint, serial
router = build_router()

View File

@@ -69,7 +69,14 @@ def build_router() -> Router:
@router.get("/", response=List[KeyOut])
def list_keys(request: HttpRequest, filters: KeysQuery = Query(...)):
"""List SSH keys for the current user, or any user if admin/operator."""
"""List SSH keys with pagination and filters.
Auth: required.
Permissions:
- If user has global `keys.view_sshkey`, returns all keys.
- Otherwise, returns only objects with `keys.view_sshkey` object permission.
Filter: user_id (honored only with global view).
"""
require_authenticated(request)
user = request.user
if _has_global_perm(request, "keys.view_sshkey"):
@@ -89,7 +96,15 @@ def build_router() -> Router:
@router.post("/", response=KeyOut)
def create_key(request: HttpRequest, payload: KeyCreateIn):
"""Create an SSH public key for the current user (admin/operator can specify user_id)."""
"""Create an SSH public key.
Auth: required.
Permissions: requires global `keys.add_sshkey`.
Rules:
- Default owner is the current user.
- If caller has global `keys.add_sshkey` and `keys.view_sshkey`, they may specify user_id.
Side effects: grants owner object perms on the new key.
"""
require_authenticated(request)
if not request.user.has_perm("keys.add_sshkey"):
raise HttpError(403, "Forbidden")
@@ -121,7 +136,11 @@ def build_router() -> Router:
@router.get("/{key_id}", response=KeyOut)
def get_key(request: HttpRequest, key_id: int):
"""Get a specific SSH key if permitted."""
"""Get a specific SSH key by id.
Auth: required.
Permissions: requires `keys.view_sshkey` on the object.
"""
require_authenticated(request)
try:
key = SSHKey.objects.get(id=key_id)
@@ -133,7 +152,11 @@ def build_router() -> Router:
@router.patch("/{key_id}", response=KeyOut)
def update_key(request: HttpRequest, key_id: int, payload: KeyUpdateIn):
"""Update key name or active state if permitted."""
"""Update key name or active state.
Auth: required.
Permissions: requires `keys.change_sshkey` on the object.
"""
require_authenticated(request)
try:
key = SSHKey.objects.get(id=key_id)
@@ -159,7 +182,12 @@ def build_router() -> Router:
@router.delete("/{key_id}", response={204: None})
def delete_key(request: HttpRequest, key_id: int):
"""Revoke an SSH key if permitted (soft delete)."""
"""Revoke (soft delete) an SSH key.
Auth: required.
Permissions: requires `keys.delete_sshkey` on the object.
Behavior: sets is_active false and revoked_at if key is active.
"""
require_authenticated(request)
try:
key = SSHKey.objects.get(id=key_id)

View File

@@ -78,21 +78,7 @@ def build_router() -> Router:
def create_server_json(request: HttpRequest, payload: ServerCreate):
"""Create a server using JSON payload (admin only)."""
require_perms(request, "servers.add_server")
server = Server.objects.create(
display_name=payload.display_name.strip(),
hostname=(payload.hostname or "").strip() or None,
ipv4=(payload.ipv4 or "").strip() or None,
ipv6=(payload.ipv6 or "").strip() or None,
)
return {
"id": server.id,
"display_name": server.display_name,
"hostname": server.hostname,
"ipv4": server.ipv4,
"ipv6": server.ipv6,
"image_url": server.image_url,
"initial": server.initial,
}
raise HttpError(403, "Servers are created via agent enrollment tokens.")
@router.post("/upload", response=ServerOut)
def create_server_multipart(
@@ -105,24 +91,7 @@ def build_router() -> Router:
):
"""Create a server with optional image upload (admin only)."""
require_perms(request, "servers.add_server")
server = Server(
display_name=display_name.strip(),
hostname=(hostname or "").strip() or None,
ipv4=(ipv4 or "").strip() or None,
ipv6=(ipv6 or "").strip() or None,
)
if image:
server.image.save(image.name, image) # type: ignore[arg-type]
server.save()
return {
"id": server.id,
"display_name": server.display_name,
"hostname": server.hostname,
"ipv4": server.ipv4,
"ipv6": server.ipv6,
"image_url": server.image_url,
"initial": server.initial,
}
raise HttpError(403, "Servers are created via agent enrollment tokens.")
@router.patch("/{server_id}", response=ServerOut)
def update_server(request: HttpRequest, server_id: int, payload: ServerUpdate):

View File

@@ -94,6 +94,8 @@ CACHES = {
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
KEYWARDEN_AGENT_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_AGENT_CERT_VALIDITY_DAYS", "90"))
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",