Coverage for src/meshadmin/server/networks/api.py: 67%
241 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-07 19:26 +0200
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-07 19:26 +0200
1import json
2import os
3import time
4from datetime import timedelta
5from typing import Optional
7import jwt
8import requests
9import structlog
10from django.conf import settings
11from django.contrib.auth import get_user_model
12from django.db import transaction
13from django.http import FileResponse, Http404, HttpRequest, HttpResponse
14from django.shortcuts import get_object_or_404
15from django.utils import timezone
16from django.utils.timezone import now
17from jwcrypto.jwk import JWK
18from ninja import NinjaAPI
19from ninja.security import HttpBearer
21from meshadmin.common import schemas
22from meshadmin.server.assets import asset_path
23from meshadmin.server.networks.models import Host, Network, NetworkMembership, Template
24from meshadmin.server.networks.services import (
25 create_network,
26 create_template,
27 enrollment,
28 generate_config_yaml,
29 generate_enrollment_token,
30)
32logger = structlog.get_logger(__name__)
34User = get_user_model()
37class KeycloakAuthBearer(HttpBearer):
38 def __init__(self):
39 super().__init__()
40 self.jwks = None
42 def get_keycloak_public_key(self):
43 if not self.jwks:
44 response = requests.get(settings.KEYCLOAK_CERTS_URL)
45 response.raise_for_status()
46 self.jwks = response.json()
47 return self.jwks
49 def authenticate(self, request: HttpRequest, token: str) -> Optional[str]:
50 try:
51 if os.getenv("MESHADMIN_TEST_MODE") == "true":
52 logger.info("test mode enabled, setting user to admin")
53 request.user = User.objects.get(username="admin")
54 return token
56 unverified_headers = jwt.get_unverified_header(token)
57 kid = unverified_headers["kid"]
58 jwks = self.get_keycloak_public_key()
59 public_key = None
60 for key in jwks["keys"]:
61 if key["kid"] == kid:
62 public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key))
63 break
65 if not public_key:
66 logger.error("No matching public key found for kid in JWKS", kid=kid)
67 return None
69 data = jwt.decode(
70 token,
71 public_key,
72 algorithms=["RS256"],
73 issuer=settings.KEYCLOAK_ISSUER,
74 options={"verify_aud": False},
75 )
76 # Client Credential Flow
77 if data.get("azp") in settings.KEYCLOAK_ALLOWED_CLIENTS:
78 request.is_client_credential_auth = True
79 return token
81 logger.info("Client credential flow not allowed", azp=data.get("azp"))
83 if data.get("azp") != settings.KEYCLOAK_ADMIN_CLIENT:
84 return None
86 email = data.get("email")
87 if not email:
88 logger.error("No email found in token")
89 return None
91 user = User.objects.filter(email=email).first()
92 if not user:
93 logger.error("User not found", email=email)
94 return None
96 request.user = user
97 return token
98 except (
99 jwt.InvalidTokenError,
100 jwt.ExpiredSignatureError,
101 requests.RequestException,
102 ) as e:
103 logger.error("Token validation failed", error=str(e))
104 return None
107keycloak_auth = KeycloakAuthBearer()
109api = NinjaAPI(title="MeshAdmin API")
112@api.get("/nebula/download/{os_name}/{architecture}/{binary_name}")
113def download_nebula_binary(request, os_name: str, architecture: str, binary_name: str):
114 valid_os = ["Linux", "Darwin"]
115 valid_binaries = ["nebula", "nebula-cert"]
116 valid_architectures = {"Darwin": ["arm64"], "Linux": ["aarch64", "x86_64"]}
118 if os_name not in valid_os:
119 return HttpResponse(
120 f"Invalid OS: {os_name}. Only Linux and Darwin are supported.", status=400
121 )
123 if (
124 os_name not in valid_architectures
125 or architecture not in valid_architectures[os_name]
126 ):
127 return HttpResponse(
128 f"Invalid architecture: {architecture} for {os_name}. "
129 f"Supported architectures for {os_name}: {valid_architectures.get(os_name, [])}",
130 status=400,
131 )
133 if binary_name not in valid_binaries:
134 return HttpResponse(f"Invalid binary name: {binary_name}", status=400)
136 binary_path = asset_path / os_name / architecture / binary_name
137 if not binary_path.exists():
138 return HttpResponse(
139 f"Binary not found: {binary_name} for {os_name}/{architecture}", status=404
140 )
142 return FileResponse(
143 open(binary_path, "rb"),
144 as_attachment=True,
145 filename=binary_name,
146 content_type="application/octet-stream",
147 )
150@api.post("/enroll", url_name="enroll", auth=None)
151@transaction.atomic
152def enroll(request: HttpRequest, client_enrollment: schemas.ClientEnrollment):
153 logger.info(
154 "enrollment request received",
155 enrollment_key=client_enrollment.enrollment_key,
156 preferred_hostname=client_enrollment.preferred_hostname,
157 public_ip=client_enrollment.public_ip,
158 )
160 try:
161 host = enrollment(
162 client_enrollment.enrollment_key,
163 client_enrollment.public_auth_key,
164 client_enrollment.enroll_on_existence,
165 client_enrollment.public_ip,
166 client_enrollment.preferred_hostname,
167 client_enrollment.public_net_key,
168 client_enrollment.interface,
169 )
170 logger.info(
171 "host enrolled successfully",
172 host_id=host.id,
173 network_name=host.network.name,
174 hostname=host.name,
175 interface=host.interface,
176 )
177 return HttpResponse(f"Enrolled host {host.id} in network {host.network.name}")
178 except Exception as e:
179 logger.error("enrollment failed", error=str(e))
180 raise
183@api.get("/config")
184def get_config(request: HttpRequest):
185 bearer_token = request.headers.get("Authorization")
186 if not bearer_token:
187 logger.warning("config request missing authorization")
188 return HttpResponse(status=403, content="Authorization bearer token missing")
190 token = bearer_token.split("Bearer ")[1]
191 try:
192 data = jwt.decode(
193 token, algorithms=["RS256"], options={"verify_signature": False}
194 )
195 try:
196 host = get_object_or_404(Host, public_auth_kid=data["kid"])
197 except Http404:
198 logger.warning("host not found for token kid", kid=data.get("kid"))
199 return HttpResponse(
200 "Host not found for authentication token",
201 status=401,
202 content_type="text/plain",
203 )
205 try:
206 pem_public_key = JWK.from_json(host.public_auth_key).export_to_pem()
207 jwt.decode(token, key=pem_public_key, algorithms=["RS256"])
208 except (jwt.InvalidTokenError, jwt.ExpiredSignatureError) as e:
209 logger.warning("invalid authentication token", error=str(e))
210 return HttpResponse(
211 "Invalid authentication token", status=401, content_type="text/plain"
212 )
214 start = time.time()
215 config = generate_config_yaml(host.pk)
216 duration = time.time() - start
218 if host.last_config_refresh != config:
219 logger.info(
220 "config changed",
221 host_id=host.id,
222 host_name=host.name,
223 duration=duration,
224 )
225 host.last_config_refresh = now()
226 host.save()
227 else:
228 logger.debug(
229 "config unchanged",
230 host_id=host.id,
231 host_name=host.name,
232 duration=duration,
233 )
235 cli_version = request.headers.get("X-Meshadmin-Version")
236 if cli_version and host.cli_version != cli_version:
237 logger.info(
238 "Mismatch detected. Updating the version in the database",
239 host_id=host.id,
240 host_name=host.name,
241 cli_version=cli_version,
242 )
243 host.cli_version = cli_version
244 host.save()
246 response = HttpResponse(config, content_type="text/yaml")
247 response["X-Update-Interval"] = str(host.network.update_interval)
248 if host.upgrade_requested:
249 logger.info(
250 "upgrade requested",
251 host_id=host.id,
252 host_name=host.name,
253 cli_version=cli_version,
254 )
255 response["X-Upgrade-Requested"] = "true"
256 host.upgrade_requested = False
257 host.save()
258 return response
259 except Exception as e:
260 logger.error("config generation failed", error=str(e))
261 return HttpResponse(
262 f"Failed to generate config: {str(e)}",
263 status=500,
264 content_type="text/plain",
265 )
268@api.post("/cleanup-ephemeral")
269def cleanup_ephemeral_hosts(request: HttpRequest):
270 bearer_token = request.headers.get("Authorization")
271 if not bearer_token:
272 logger.warning("cleanup request missing authorization")
273 return HttpResponse(status=403, content="Authorization bearer token missing")
275 token = bearer_token.split("Bearer ")[1]
276 try:
277 data = jwt.decode(
278 token, algorithms=["RS256"], options={"verify_signature": False}
279 )
280 try:
281 host = get_object_or_404(Host, public_auth_kid=data["kid"])
282 except Http404:
283 logger.warning("host not found for token kid", kid=data.get("kid"))
284 return HttpResponse(
285 "Host not found for authentication token",
286 status=401,
287 content_type="text/plain",
288 )
290 try:
291 pem_public_key = JWK.from_json(host.public_auth_key).export_to_pem()
292 jwt.decode(token, key=pem_public_key, algorithms=["RS256"])
293 except (jwt.InvalidTokenError, jwt.ExpiredSignatureError) as e:
294 logger.warning("invalid authentication token", error=str(e))
295 return HttpResponse(
296 "Invalid authentication token", status=401, content_type="text/plain"
297 )
299 cutoff = timezone.now() - timedelta(minutes=10)
300 stale_hosts = Host.objects.filter(
301 network=host.network, is_ephemeral=True, last_config_refresh__lt=cutoff
302 )
303 count = 0
304 for stale_host in stale_hosts:
305 logger.info(
306 "removing stale ephemeral host",
307 host_id=stale_host.id,
308 host_name=stale_host.name,
309 network=stale_host.network.name,
310 last_refresh=stale_host.last_config_refresh,
311 )
312 stale_host.delete()
313 count += 1
315 return {"removed_count": count}
317 except Exception as e:
318 logger.error("cleanup failed", error=str(e))
319 return HttpResponse(
320 f"Failed to clean up ephemeral hosts: {str(e)}",
321 status=500,
322 content_type="text/plain",
323 )
326@api.post("/networks", response=schemas.NetworkResponse, auth=keycloak_auth)
327def create_network_endpoint(request: HttpRequest, data: schemas.NetworkCreate):
328 network = create_network(
329 network_name=data.name, network_cidr=data.cidr, user=request.user
330 )
331 return {"id": network.id, "name": network.name, "cidr": network.cidr}
334@api.get("/networks", auth=keycloak_auth)
335def list_networks(request: HttpRequest):
336 if request.user.is_superuser:
337 networks = Network.objects.all()
338 else:
339 networks = Network.objects.filter(
340 memberships__user=request.user,
341 memberships__role=NetworkMembership.Role.ADMIN,
342 )
343 return [
344 {"id": network.id, "name": network.name, "cidr": network.cidr}
345 for network in networks
346 ]
349@api.delete("/networks/{name}", auth=keycloak_auth)
350def delete_network(request: HttpRequest, name: str):
351 try:
352 if not request.user.is_superuser:
353 network = Network.objects.filter(
354 memberships__user=request.user,
355 memberships__role=NetworkMembership.Role.ADMIN,
356 ).get(name=name)
357 else:
358 network = Network.objects.get(name=name)
359 network.delete()
360 return {"message": f"Network {name} deleted"}
361 except Network.DoesNotExist:
362 return HttpResponse(status=404, content=f"Network {name} not found")
365@api.post("/templates", response=schemas.TemplateResponse, auth=keycloak_auth)
366def create_template_endpoint(request: HttpRequest, data: schemas.TemplateCreate):
367 try:
368 network = Network.objects.get(name=data.network_name)
369 except Network.DoesNotExist:
370 return HttpResponse(
371 status=404, content=f"Network {data.network_name} not found"
372 )
373 if not request.user.is_superuser:
374 if not network.memberships.filter(
375 user=request.user, role=NetworkMembership.Role.ADMIN
376 ).exists():
377 return HttpResponse(status=403, content="Permission denied")
379 template = create_template(
380 name=data.name,
381 network_name=data.network_name,
382 is_lighthouse=data.is_lighthouse,
383 is_relay=data.is_relay,
384 use_relay=data.use_relay,
385 )
386 return {
387 "id": template.id,
388 "name": template.name,
389 "enrollment_key": template.enrollment_key,
390 }
393@api.delete("/templates/{name}", auth=keycloak_auth)
394def delete_template(request: HttpRequest, name: str):
395 try:
396 if not request.user.is_superuser:
397 template = Template.objects.filter(
398 network__memberships__user=request.user,
399 network__memberships__role=NetworkMembership.Role.ADMIN,
400 ).get(name=name)
401 else:
402 template = Template.objects.get(name=name)
403 template.delete()
404 return {"message": f"Template {name} deleted"}
405 except Template.DoesNotExist:
406 return HttpResponse(status=404, content=f"Template {name} not found")
409@api.get("/templates/{name}/token", auth=keycloak_auth)
410def get_template_token(request: HttpRequest, name: str, ttl: int = None):
411 try:
412 is_client_auth = getattr(request, "is_client_credential_auth", False)
413 if is_client_auth:
414 logger.info("Authenticated with client credential flow", template_name=name)
415 template = Template.objects.get(name=name)
416 else:
417 if not request.user.is_superuser:
418 template = Template.objects.filter(
419 network__memberships__user=request.user,
420 network__memberships__role=NetworkMembership.Role.ADMIN,
421 ).get(name=name)
422 else:
423 template = Template.objects.get(name=name)
424 return {
425 "token": generate_enrollment_token(template, ttl=ttl),
426 "template_id": template.id,
427 }
428 except Template.DoesNotExist:
429 return HttpResponse(status=404, content=f"Template {name} not found")
432@api.delete("/hosts/{name}", auth=keycloak_auth)
433def delete_host(request: HttpRequest, name: str):
434 try:
435 if not request.user.is_superuser:
436 host = Host.objects.filter(
437 network__memberships__user=request.user,
438 network__memberships__role=NetworkMembership.Role.ADMIN,
439 ).get(name=name)
440 else:
441 host = Host.objects.get(name=name)
442 host.delete()
443 return {"message": f"Host {name} deleted"}
444 except Host.DoesNotExist:
445 return HttpResponse(status=404, content=f"Host {name} not found")
448@api.get("/test")
449def test(request: HttpRequest):
450 return {"message": "Test endpoint"}