Agent retries on connection loss, sends connection info (v4 v6) Uses system CA for mTLS. Removed server endpoints.
This commit is contained in:
@@ -6,6 +6,7 @@ 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.core.validators import validate_ipv4_address, validate_ipv6_address
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
@@ -44,6 +45,8 @@ class AgentEnrollIn(Schema):
|
||||
token: str
|
||||
csr_pem: str
|
||||
host: Optional[str] = None
|
||||
ipv4: Optional[str] = None
|
||||
ipv6: Optional[str] = None
|
||||
|
||||
|
||||
class AgentEnrollOut(Schema):
|
||||
@@ -73,6 +76,12 @@ class LogIngestOut(Schema):
|
||||
accepted: int
|
||||
|
||||
|
||||
class AgentHeartbeatIn(Schema):
|
||||
host: Optional[str] = None
|
||||
ipv4: Optional[str] = None
|
||||
ipv6: Optional[str] = None
|
||||
|
||||
|
||||
def build_router() -> Router:
|
||||
router = Router()
|
||||
|
||||
@@ -99,11 +108,18 @@ def build_router() -> Router:
|
||||
hostname = host
|
||||
except ValidationError:
|
||||
hostname = None
|
||||
ipv4 = _normalize_ip(payload.ipv4, 4)
|
||||
ipv6 = _normalize_ip(payload.ipv6, 6)
|
||||
|
||||
csr = _load_csr((payload.csr_pem or "").strip())
|
||||
try:
|
||||
with transaction.atomic():
|
||||
server = Server.objects.create(display_name=display_name, hostname=hostname)
|
||||
server = Server.objects.create(
|
||||
display_name=display_name,
|
||||
hostname=hostname,
|
||||
ipv4=ipv4,
|
||||
ipv6=ipv6,
|
||||
)
|
||||
token.mark_used(server)
|
||||
token.save(update_fields=["used_at", "server"])
|
||||
cert_pem, ca_pem, fingerprint, serial = _issue_client_cert(csr, host, server.id)
|
||||
@@ -189,6 +205,38 @@ def build_router() -> Router:
|
||||
# TODO: enqueue to Valkey and persist to SQLite slices.
|
||||
return LogIngestOut(status="accepted", accepted=len(payload))
|
||||
|
||||
@router.post("/servers/{server_id}/heartbeat", response=SyncReportOut, auth=None)
|
||||
@csrf_exempt
|
||||
def heartbeat(request: HttpRequest, server_id: int, payload: AgentHeartbeatIn = Body(...)):
|
||||
"""Update server host metadata (mTLS required at the edge)."""
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
except Server.DoesNotExist:
|
||||
raise HttpError(404, "Server not found")
|
||||
updates: dict[str, str] = {}
|
||||
host = (payload.host or "").strip()[:253]
|
||||
if host:
|
||||
try:
|
||||
hostname_validator(host)
|
||||
if server.hostname != host:
|
||||
updates["hostname"] = host
|
||||
except ValidationError:
|
||||
pass
|
||||
ipv4 = _normalize_ip(payload.ipv4, 4)
|
||||
if ipv4 and server.ipv4 != ipv4:
|
||||
updates["ipv4"] = ipv4
|
||||
ipv6 = _normalize_ip(payload.ipv6, 6)
|
||||
if ipv6 and server.ipv6 != ipv6:
|
||||
updates["ipv6"] = ipv6
|
||||
if updates:
|
||||
for field, value in updates.items():
|
||||
setattr(server, field, value)
|
||||
try:
|
||||
server.save(update_fields=list(updates.keys()))
|
||||
except IntegrityError:
|
||||
raise HttpError(409, "Server address already in use")
|
||||
return SyncReportOut(status="ok")
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@@ -250,4 +298,17 @@ def _issue_client_cert(
|
||||
return cert_pem, ca_pem, fingerprint, serial
|
||||
|
||||
|
||||
def _normalize_ip(value: Optional[str], version: int) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
if version == 4:
|
||||
validate_ipv4_address(value)
|
||||
else:
|
||||
validate_ipv6_address(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
@@ -2,10 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from django.db import IntegrityError
|
||||
from django.http import HttpRequest
|
||||
from ninja import File, Form, Router, Schema
|
||||
from ninja.files import UploadedFile
|
||||
from ninja import Router, Schema
|
||||
from ninja.errors import HttpError
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from apps.core.rbac import require_perms
|
||||
@@ -22,18 +20,8 @@ class ServerOut(Schema):
|
||||
initial: str
|
||||
|
||||
|
||||
class ServerCreate(Schema):
|
||||
display_name: str
|
||||
hostname: Optional[str] = None
|
||||
ipv4: Optional[str] = None
|
||||
ipv6: Optional[str] = None
|
||||
|
||||
|
||||
class ServerUpdate(Schema):
|
||||
display_name: Optional[str] = None
|
||||
hostname: Optional[str] = None
|
||||
ipv4: Optional[str] = None
|
||||
ipv6: Optional[str] = None
|
||||
|
||||
|
||||
def build_router() -> Router:
|
||||
@@ -87,55 +75,21 @@ def build_router() -> Router:
|
||||
"initial": server.initial,
|
||||
}
|
||||
|
||||
@router.post("/", response=ServerOut)
|
||||
def create_server_json(request: HttpRequest, payload: ServerCreate):
|
||||
"""Create a server using JSON payload (admin only)."""
|
||||
require_perms(request, "servers.add_server")
|
||||
raise HttpError(403, "Servers are created via agent enrollment tokens.")
|
||||
|
||||
@router.post("/upload", response=ServerOut)
|
||||
def create_server_multipart(
|
||||
request: HttpRequest,
|
||||
display_name: str = Form(...),
|
||||
hostname: Optional[str] = Form(None),
|
||||
ipv4: Optional[str] = Form(None),
|
||||
ipv6: Optional[str] = Form(None),
|
||||
image: Optional[UploadedFile] = File(None),
|
||||
):
|
||||
"""Create a server with optional image upload (admin only)."""
|
||||
require_perms(request, "servers.add_server")
|
||||
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):
|
||||
"""Update server fields (admin only)."""
|
||||
"""Update server display name (admin only)."""
|
||||
require_perms(request, "servers.change_server")
|
||||
if (
|
||||
payload.display_name is None
|
||||
and payload.hostname is None
|
||||
and payload.ipv4 is None
|
||||
and payload.ipv6 is None
|
||||
):
|
||||
if payload.display_name is None:
|
||||
raise HttpError(422, {"detail": "No fields provided."})
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
except Server.DoesNotExist:
|
||||
raise HttpError(404, "Not Found")
|
||||
if payload.display_name is not None:
|
||||
display_name = payload.display_name.strip()
|
||||
if not display_name:
|
||||
raise HttpError(422, {"display_name": ["Display name cannot be empty."]})
|
||||
server.display_name = display_name
|
||||
if payload.hostname is not None:
|
||||
server.hostname = (payload.hostname or "").strip() or None
|
||||
if payload.ipv4 is not None:
|
||||
server.ipv4 = (payload.ipv4 or "").strip() or None
|
||||
if payload.ipv6 is not None:
|
||||
server.ipv6 = (payload.ipv6 or "").strip() or None
|
||||
try:
|
||||
server.save()
|
||||
except IntegrityError:
|
||||
raise HttpError(422, {"detail": "Unique constraint violated."})
|
||||
display_name = payload.display_name.strip()
|
||||
if not display_name:
|
||||
raise HttpError(422, {"display_name": ["Display name cannot be empty."]})
|
||||
server.display_name = display_name
|
||||
server.save(update_fields=["display_name"])
|
||||
return {
|
||||
"id": server.id,
|
||||
"display_name": server.display_name,
|
||||
@@ -146,17 +100,6 @@ def build_router() -> Router:
|
||||
"initial": server.initial,
|
||||
}
|
||||
|
||||
@router.delete("/{server_id}", response={204: None})
|
||||
def delete_server(request: HttpRequest, server_id: int):
|
||||
"""Delete a server by id (admin only)."""
|
||||
require_perms(request, "servers.delete_server")
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
except Server.DoesNotExist:
|
||||
raise HttpError(404, "Not Found")
|
||||
server.delete()
|
||||
return 204, None
|
||||
|
||||
return router
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user