Coverage for src/meshadmin/server/networks/api.py: 70%
223 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-22 07:09 +0200
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-22 07:09 +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")
67 return None
69 data = jwt.decode(
70 token,
71 public_key,
72 algorithms=["RS256"],
73 options={"verify_signature": True},
74 issuer=settings.KEYCLOAK_ISSUER,
75 )
76 if data.get("azp") != settings.KEYCLOAK_ADMIN_CLIENT:
77 return None
79 email = data.get("email")
80 if not email:
81 logger.error("No email found in token")
82 return None
84 user = User.objects.filter(email=email).first()
85 if not user:
86 logger.error("User not found", email=email)
87 return None
89 request.user = user
90 return token
91 except (
92 jwt.InvalidTokenError,
93 jwt.ExpiredSignatureError,
94 requests.RequestException,
95 ) as e:
96 logger.error("Token validation failed", error=str(e))
97 return None
100keycloak_auth = KeycloakAuthBearer()
102api = NinjaAPI(title="MeshAdmin API")
105@api.get("/nebula/download/{os_name}/{architecture}/{binary_name}")
106def download_nebula_binary(request, os_name: str, architecture: str, binary_name: str):
107 valid_os = ["Linux", "Darwin"]
108 valid_binaries = ["nebula", "nebula-cert"]
109 valid_architectures = {"Darwin": ["arm64"], "Linux": ["aarch64", "x86_64"]}
111 if os_name not in valid_os:
112 return HttpResponse(
113 f"Invalid OS: {os_name}. Only Linux and Darwin are supported.", status=400
114 )
116 if (
117 os_name not in valid_architectures
118 or architecture not in valid_architectures[os_name]
119 ):
120 return HttpResponse(
121 f"Invalid architecture: {architecture} for {os_name}. "
122 f"Supported architectures for {os_name}: {valid_architectures.get(os_name, [])}",
123 status=400,
124 )
126 if binary_name not in valid_binaries:
127 return HttpResponse(f"Invalid binary name: {binary_name}", status=400)
129 binary_path = asset_path / os_name / architecture / binary_name
130 if not binary_path.exists():
131 return HttpResponse(
132 f"Binary not found: {binary_name} for {os_name}/{architecture}", status=404
133 )
135 return FileResponse(
136 open(binary_path, "rb"),
137 as_attachment=True,
138 filename=binary_name,
139 content_type="application/octet-stream",
140 )
143@api.post("/enroll", url_name="enroll", auth=None)
144@transaction.atomic
145def enroll(request: HttpRequest, client_enrollment: schemas.ClientEnrollment):
146 logger.info(
147 "enrollment request received",
148 enrollment_key=client_enrollment.enrollment_key,
149 preferred_hostname=client_enrollment.preferred_hostname,
150 public_ip=client_enrollment.public_ip,
151 )
153 try:
154 host = enrollment(
155 client_enrollment.enrollment_key,
156 client_enrollment.public_auth_key,
157 client_enrollment.enroll_on_existence,
158 client_enrollment.public_ip,
159 client_enrollment.preferred_hostname,
160 client_enrollment.public_net_key,
161 client_enrollment.interface,
162 )
163 logger.info(
164 "host enrolled successfully",
165 host_id=host.id,
166 network_name=host.network.name,
167 hostname=host.name,
168 interface=host.interface,
169 )
170 return HttpResponse(f"Enrolled host {host.id} in network {host.network.name}")
171 except Exception as e:
172 logger.error("enrollment failed", error=str(e))
173 raise
176@api.get("/config")
177def get_config(request: HttpRequest):
178 bearer_token = request.headers.get("Authorization")
179 if not bearer_token:
180 logger.warning("config request missing authorization")
181 return HttpResponse(status=403, content="Authorization bearer token missing")
183 token = bearer_token.split("Bearer ")[1]
184 try:
185 data = jwt.decode(
186 token, algorithms=["RS256"], options={"verify_signature": False}
187 )
188 try:
189 host = get_object_or_404(Host, public_auth_kid=data["kid"])
190 except Http404:
191 logger.warning("host not found for token kid", kid=data.get("kid"))
192 return HttpResponse(
193 "Host not found for authentication token",
194 status=401,
195 content_type="text/plain",
196 )
198 try:
199 pem_public_key = JWK.from_json(host.public_auth_key).export_to_pem()
200 jwt.decode(token, key=pem_public_key, algorithms=["RS256"])
201 except (jwt.InvalidTokenError, jwt.ExpiredSignatureError) as e:
202 logger.warning("invalid authentication token", error=str(e))
203 return HttpResponse(
204 "Invalid authentication token", status=401, content_type="text/plain"
205 )
207 start = time.time()
208 config = generate_config_yaml(host.pk)
209 duration = time.time() - start
211 if host.last_config_refresh != config:
212 logger.info(
213 "config changed",
214 host_id=host.id,
215 host_name=host.name,
216 duration=duration,
217 )
218 host.last_config_refresh = now()
219 host.save()
220 else:
221 logger.debug(
222 "config unchanged",
223 host_id=host.id,
224 host_name=host.name,
225 duration=duration,
226 )
228 response = HttpResponse(config, content_type="text/yaml")
229 response["X-Update-Interval"] = str(host.network.update_interval)
230 return response
231 except Exception as e:
232 logger.error("config generation failed", error=str(e))
233 return HttpResponse(
234 f"Failed to generate config: {str(e)}",
235 status=500,
236 content_type="text/plain",
237 )
240@api.post("/cleanup-ephemeral")
241def cleanup_ephemeral_hosts(request: HttpRequest):
242 bearer_token = request.headers.get("Authorization")
243 if not bearer_token:
244 logger.warning("cleanup request missing authorization")
245 return HttpResponse(status=403, content="Authorization bearer token missing")
247 token = bearer_token.split("Bearer ")[1]
248 try:
249 data = jwt.decode(
250 token, algorithms=["RS256"], options={"verify_signature": False}
251 )
252 try:
253 host = get_object_or_404(Host, public_auth_kid=data["kid"])
254 except Http404:
255 logger.warning("host not found for token kid", kid=data.get("kid"))
256 return HttpResponse(
257 "Host not found for authentication token",
258 status=401,
259 content_type="text/plain",
260 )
262 try:
263 pem_public_key = JWK.from_json(host.public_auth_key).export_to_pem()
264 jwt.decode(token, key=pem_public_key, algorithms=["RS256"])
265 except (jwt.InvalidTokenError, jwt.ExpiredSignatureError) as e:
266 logger.warning("invalid authentication token", error=str(e))
267 return HttpResponse(
268 "Invalid authentication token", status=401, content_type="text/plain"
269 )
271 cutoff = timezone.now() - timedelta(minutes=10)
272 stale_hosts = Host.objects.filter(
273 network=host.network, is_ephemeral=True, last_config_refresh__lt=cutoff
274 )
275 count = 0
276 for stale_host in stale_hosts:
277 logger.info(
278 "removing stale ephemeral host",
279 host_id=stale_host.id,
280 host_name=stale_host.name,
281 network=stale_host.network.name,
282 last_refresh=stale_host.last_config_refresh,
283 )
284 stale_host.delete()
285 count += 1
287 return {"removed_count": count}
289 except Exception as e:
290 logger.error("cleanup failed", error=str(e))
291 return HttpResponse(
292 f"Failed to clean up ephemeral hosts: {str(e)}",
293 status=500,
294 content_type="text/plain",
295 )
298@api.post("/networks", response=schemas.NetworkResponse, auth=keycloak_auth)
299def create_network_endpoint(request: HttpRequest, data: schemas.NetworkCreate):
300 network = create_network(
301 network_name=data.name, network_cidr=data.cidr, user=request.user
302 )
303 return {"id": network.id, "name": network.name, "cidr": network.cidr}
306@api.get("/networks", auth=keycloak_auth)
307def list_networks(request: HttpRequest):
308 if request.user.is_superuser:
309 networks = Network.objects.all()
310 else:
311 networks = Network.objects.filter(
312 memberships__user=request.user,
313 memberships__role=NetworkMembership.Role.ADMIN,
314 )
315 return [
316 {"id": network.id, "name": network.name, "cidr": network.cidr}
317 for network in networks
318 ]
321@api.delete("/networks/{name}", auth=keycloak_auth)
322def delete_network(request: HttpRequest, name: str):
323 try:
324 if not request.user.is_superuser:
325 network = Network.objects.filter(
326 memberships__user=request.user,
327 memberships__role=NetworkMembership.Role.ADMIN,
328 ).get(name=name)
329 else:
330 network = Network.objects.get(name=name)
331 network.delete()
332 return {"message": f"Network {name} deleted"}
333 except Network.DoesNotExist:
334 return HttpResponse(status=404, content=f"Network {name} not found")
337@api.post("/templates", response=schemas.TemplateResponse, auth=keycloak_auth)
338def create_template_endpoint(request: HttpRequest, data: schemas.TemplateCreate):
339 try:
340 network = Network.objects.get(name=data.network_name)
341 except Network.DoesNotExist:
342 return HttpResponse(
343 status=404, content=f"Network {data.network_name} not found"
344 )
345 if not request.user.is_superuser:
346 if not network.memberships.filter(
347 user=request.user, role=NetworkMembership.Role.ADMIN
348 ).exists():
349 return HttpResponse(status=403, content="Permission denied")
351 template = create_template(
352 name=data.name,
353 network_name=data.network_name,
354 is_lighthouse=data.is_lighthouse,
355 is_relay=data.is_relay,
356 use_relay=data.use_relay,
357 )
358 return {
359 "id": template.id,
360 "name": template.name,
361 "enrollment_key": template.enrollment_key,
362 }
365@api.delete("/templates/{name}", auth=keycloak_auth)
366def delete_template(request: HttpRequest, name: str):
367 try:
368 if not request.user.is_superuser:
369 template = Template.objects.filter(
370 network__memberships__user=request.user,
371 network__memberships__role=NetworkMembership.Role.ADMIN,
372 ).get(name=name)
373 else:
374 template = Template.objects.get(name=name)
375 template.delete()
376 return {"message": f"Template {name} deleted"}
377 except Template.DoesNotExist:
378 return HttpResponse(status=404, content=f"Template {name} not found")
381@api.get("/templates/{name}/token", auth=keycloak_auth)
382def get_template_token(request: HttpRequest, name: str):
383 try:
384 if not request.user.is_superuser:
385 template = Template.objects.filter(
386 network__memberships__user=request.user,
387 network__memberships__role=NetworkMembership.Role.ADMIN,
388 ).get(name=name)
389 else:
390 template = Template.objects.get(name=name)
391 return {
392 "token": generate_enrollment_token(template),
393 "template_id": template.id,
394 }
395 except Template.DoesNotExist:
396 return HttpResponse(status=404, content=f"Template {name} not found")
399@api.delete("/hosts/{name}", auth=keycloak_auth)
400def delete_host(request: HttpRequest, name: str):
401 try:
402 if not request.user.is_superuser:
403 host = Host.objects.filter(
404 network__memberships__user=request.user,
405 network__memberships__role=NetworkMembership.Role.ADMIN,
406 ).get(name=name)
407 else:
408 host = Host.objects.get(name=name)
409 host.delete()
410 return {"message": f"Host {name} deleted"}
411 except Host.DoesNotExist:
412 return HttpResponse(status=404, content=f"Host {name} not found")
415@api.get("/test")
416def test(request: HttpRequest):
417 return {"message": "Test endpoint"}