Coverage for src/meshadmin/server/networks/api.py: 68%
233 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-06 11:34 +0200
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-06 11:34 +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 cli_version = request.headers.get("X-Meshadmin-Version")
229 if cli_version and host.cli_version != cli_version:
230 logger.info(
231 "Mismatch detected. Updating the version in the database",
232 host_id=host.id,
233 host_name=host.name,
234 cli_version=cli_version,
235 )
236 host.cli_version = cli_version
237 host.save()
239 response = HttpResponse(config, content_type="text/yaml")
240 response["X-Update-Interval"] = str(host.network.update_interval)
241 if host.upgrade_requested:
242 logger.info(
243 "upgrade requested",
244 host_id=host.id,
245 host_name=host.name,
246 cli_version=cli_version,
247 )
248 response["X-Upgrade-Requested"] = "true"
249 host.upgrade_requested = False
250 host.save()
251 return response
252 except Exception as e:
253 logger.error("config generation failed", error=str(e))
254 return HttpResponse(
255 f"Failed to generate config: {str(e)}",
256 status=500,
257 content_type="text/plain",
258 )
261@api.post("/cleanup-ephemeral")
262def cleanup_ephemeral_hosts(request: HttpRequest):
263 bearer_token = request.headers.get("Authorization")
264 if not bearer_token:
265 logger.warning("cleanup request missing authorization")
266 return HttpResponse(status=403, content="Authorization bearer token missing")
268 token = bearer_token.split("Bearer ")[1]
269 try:
270 data = jwt.decode(
271 token, algorithms=["RS256"], options={"verify_signature": False}
272 )
273 try:
274 host = get_object_or_404(Host, public_auth_kid=data["kid"])
275 except Http404:
276 logger.warning("host not found for token kid", kid=data.get("kid"))
277 return HttpResponse(
278 "Host not found for authentication token",
279 status=401,
280 content_type="text/plain",
281 )
283 try:
284 pem_public_key = JWK.from_json(host.public_auth_key).export_to_pem()
285 jwt.decode(token, key=pem_public_key, algorithms=["RS256"])
286 except (jwt.InvalidTokenError, jwt.ExpiredSignatureError) as e:
287 logger.warning("invalid authentication token", error=str(e))
288 return HttpResponse(
289 "Invalid authentication token", status=401, content_type="text/plain"
290 )
292 cutoff = timezone.now() - timedelta(minutes=10)
293 stale_hosts = Host.objects.filter(
294 network=host.network, is_ephemeral=True, last_config_refresh__lt=cutoff
295 )
296 count = 0
297 for stale_host in stale_hosts:
298 logger.info(
299 "removing stale ephemeral host",
300 host_id=stale_host.id,
301 host_name=stale_host.name,
302 network=stale_host.network.name,
303 last_refresh=stale_host.last_config_refresh,
304 )
305 stale_host.delete()
306 count += 1
308 return {"removed_count": count}
310 except Exception as e:
311 logger.error("cleanup failed", error=str(e))
312 return HttpResponse(
313 f"Failed to clean up ephemeral hosts: {str(e)}",
314 status=500,
315 content_type="text/plain",
316 )
319@api.post("/networks", response=schemas.NetworkResponse, auth=keycloak_auth)
320def create_network_endpoint(request: HttpRequest, data: schemas.NetworkCreate):
321 network = create_network(
322 network_name=data.name, network_cidr=data.cidr, user=request.user
323 )
324 return {"id": network.id, "name": network.name, "cidr": network.cidr}
327@api.get("/networks", auth=keycloak_auth)
328def list_networks(request: HttpRequest):
329 if request.user.is_superuser:
330 networks = Network.objects.all()
331 else:
332 networks = Network.objects.filter(
333 memberships__user=request.user,
334 memberships__role=NetworkMembership.Role.ADMIN,
335 )
336 return [
337 {"id": network.id, "name": network.name, "cidr": network.cidr}
338 for network in networks
339 ]
342@api.delete("/networks/{name}", auth=keycloak_auth)
343def delete_network(request: HttpRequest, name: str):
344 try:
345 if not request.user.is_superuser:
346 network = Network.objects.filter(
347 memberships__user=request.user,
348 memberships__role=NetworkMembership.Role.ADMIN,
349 ).get(name=name)
350 else:
351 network = Network.objects.get(name=name)
352 network.delete()
353 return {"message": f"Network {name} deleted"}
354 except Network.DoesNotExist:
355 return HttpResponse(status=404, content=f"Network {name} not found")
358@api.post("/templates", response=schemas.TemplateResponse, auth=keycloak_auth)
359def create_template_endpoint(request: HttpRequest, data: schemas.TemplateCreate):
360 try:
361 network = Network.objects.get(name=data.network_name)
362 except Network.DoesNotExist:
363 return HttpResponse(
364 status=404, content=f"Network {data.network_name} not found"
365 )
366 if not request.user.is_superuser:
367 if not network.memberships.filter(
368 user=request.user, role=NetworkMembership.Role.ADMIN
369 ).exists():
370 return HttpResponse(status=403, content="Permission denied")
372 template = create_template(
373 name=data.name,
374 network_name=data.network_name,
375 is_lighthouse=data.is_lighthouse,
376 is_relay=data.is_relay,
377 use_relay=data.use_relay,
378 )
379 return {
380 "id": template.id,
381 "name": template.name,
382 "enrollment_key": template.enrollment_key,
383 }
386@api.delete("/templates/{name}", auth=keycloak_auth)
387def delete_template(request: HttpRequest, name: str):
388 try:
389 if not request.user.is_superuser:
390 template = Template.objects.filter(
391 network__memberships__user=request.user,
392 network__memberships__role=NetworkMembership.Role.ADMIN,
393 ).get(name=name)
394 else:
395 template = Template.objects.get(name=name)
396 template.delete()
397 return {"message": f"Template {name} deleted"}
398 except Template.DoesNotExist:
399 return HttpResponse(status=404, content=f"Template {name} not found")
402@api.get("/templates/{name}/token", auth=keycloak_auth)
403def get_template_token(request: HttpRequest, name: str):
404 try:
405 if not request.user.is_superuser:
406 template = Template.objects.filter(
407 network__memberships__user=request.user,
408 network__memberships__role=NetworkMembership.Role.ADMIN,
409 ).get(name=name)
410 else:
411 template = Template.objects.get(name=name)
412 return {
413 "token": generate_enrollment_token(template),
414 "template_id": template.id,
415 }
416 except Template.DoesNotExist:
417 return HttpResponse(status=404, content=f"Template {name} not found")
420@api.delete("/hosts/{name}", auth=keycloak_auth)
421def delete_host(request: HttpRequest, name: str):
422 try:
423 if not request.user.is_superuser:
424 host = Host.objects.filter(
425 network__memberships__user=request.user,
426 network__memberships__role=NetworkMembership.Role.ADMIN,
427 ).get(name=name)
428 else:
429 host = Host.objects.get(name=name)
430 host.delete()
431 return {"message": f"Host {name} deleted"}
432 except Host.DoesNotExist:
433 return HttpResponse(status=404, content=f"Host {name} not found")
436@api.get("/test")
437def test(request: HttpRequest):
438 return {"message": "Test endpoint"}