Coverage for src/meshadmin/server/networks/api.py: 71%
215 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-10 16:08 +0200
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-10 16:08 +0200
1import json
2import time
3from datetime import timedelta
4from typing import Optional
6import jwt
7import requests
8import structlog
9from django.conf import settings
10from django.contrib.auth import get_user_model
11from django.db import transaction
12from django.http import FileResponse, Http404, HttpRequest, HttpResponse
13from django.shortcuts import get_object_or_404
14from django.utils import timezone
15from django.utils.timezone import now
16from jwcrypto.jwk import JWK
17from ninja import NinjaAPI
18from ninja.security import HttpBearer
20from meshadmin.common import schemas
21from meshadmin.server.assets import asset_path
22from meshadmin.server.networks.models import Host, Network, NetworkMembership, Template
23from meshadmin.server.networks.services import (
24 create_network,
25 create_template,
26 enrollment,
27 generate_config_yaml,
28 generate_enrollment_token,
29)
31logger = structlog.get_logger(__name__)
33User = get_user_model()
36class KeycloakAuthBearer(HttpBearer):
37 def __init__(self):
38 super().__init__()
39 self.jwks = None
41 def get_keycloak_public_key(self):
42 if not self.jwks:
43 response = requests.get(settings.KEYCLOAK_CERTS_URL)
44 response.raise_for_status()
45 self.jwks = response.json()
46 return self.jwks
48 def authenticate(self, request: HttpRequest, token: str) -> Optional[str]:
49 try:
50 unverified_headers = jwt.get_unverified_header(token)
51 kid = unverified_headers["kid"]
52 jwks = self.get_keycloak_public_key()
53 public_key = None
54 for key in jwks["keys"]:
55 if key["kid"] == kid:
56 public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key))
57 break
59 if not public_key:
60 logger.error("No matching public key found")
61 return None
63 data = jwt.decode(
64 token,
65 public_key,
66 algorithms=["RS256"],
67 options={"verify_signature": True},
68 issuer=settings.KEYCLOAK_ISSUER,
69 )
70 if data.get("azp") != settings.KEYCLOAK_ADMIN_CLIENT:
71 return None
73 email = data.get("email")
74 if not email:
75 logger.error("No email found in token")
76 return None
78 user = User.objects.filter(email=email).first()
79 if not user:
80 logger.error("User not found", email=email)
81 return None
83 request.user = user
84 return token
85 except (
86 jwt.InvalidTokenError,
87 jwt.ExpiredSignatureError,
88 requests.RequestException,
89 ) as e:
90 logger.error("Token validation failed", error=str(e))
91 return None
94keycloak_auth = KeycloakAuthBearer()
96api = NinjaAPI(title="MeshAdmin API")
99@api.get("/nebula/download/{os_name}/{architecture}/{binary_name}")
100def download_nebula_binary(request, os_name: str, architecture: str, binary_name: str):
101 valid_os = ["Linux", "Darwin"]
102 valid_binaries = ["nebula", "nebula-cert"]
103 valid_architectures = {"Darwin": ["arm64"], "Linux": ["aarch64", "x86_64"]}
105 if os_name not in valid_os:
106 return HttpResponse(
107 f"Invalid OS: {os_name}. Only Linux and Darwin are supported.", status=400
108 )
110 if (
111 os_name not in valid_architectures
112 or architecture not in valid_architectures[os_name]
113 ):
114 return HttpResponse(
115 f"Invalid architecture: {architecture} for {os_name}. "
116 f"Supported architectures for {os_name}: {valid_architectures.get(os_name, [])}",
117 status=400,
118 )
120 if binary_name not in valid_binaries:
121 return HttpResponse(f"Invalid binary name: {binary_name}", status=400)
123 binary_path = asset_path / os_name / architecture / binary_name
124 if not binary_path.exists():
125 return HttpResponse(
126 f"Binary not found: {binary_name} for {os_name}/{architecture}", status=404
127 )
129 return FileResponse(
130 open(binary_path, "rb"),
131 as_attachment=True,
132 filename=binary_name,
133 content_type="application/octet-stream",
134 )
137@api.post("/enroll", url_name="enroll", auth=None)
138@transaction.atomic
139def enroll(request: HttpRequest, client_enrollment: schemas.ClientEnrollment):
140 logger.info(
141 "enrollment request received",
142 enrollment_key=client_enrollment.enrollment_key,
143 preferred_hostname=client_enrollment.preferred_hostname,
144 public_ip=client_enrollment.public_ip,
145 )
147 try:
148 host = enrollment(
149 client_enrollment.enrollment_key,
150 client_enrollment.public_auth_key,
151 client_enrollment.enroll_on_existence,
152 client_enrollment.public_ip,
153 client_enrollment.preferred_hostname,
154 client_enrollment.public_net_key,
155 client_enrollment.interface,
156 )
157 logger.info(
158 "host enrolled successfully",
159 host_id=host.id,
160 network_name=host.network.name,
161 hostname=host.name,
162 interface=host.interface,
163 )
164 return HttpResponse(f"Enrolled host {host.id} in network {host.network.name}")
165 except Exception as e:
166 logger.error("enrollment failed", error=str(e))
167 raise
170@api.get("/config")
171def get_config(request: HttpRequest):
172 bearer_token = request.headers.get("Authorization")
173 if not bearer_token:
174 logger.warning("config request missing authorization")
175 return HttpResponse(status=403, content="Authorization bearer token missing")
177 token = bearer_token.split("Bearer ")[1]
178 try:
179 data = jwt.decode(
180 token, algorithms=["RS256"], options={"verify_signature": False}
181 )
182 try:
183 host = get_object_or_404(Host, public_auth_kid=data["kid"])
184 except Http404:
185 logger.warning("host not found for token kid", kid=data.get("kid"))
186 return HttpResponse(
187 "Host not found for authentication token",
188 status=401,
189 content_type="text/plain",
190 )
192 try:
193 pem_public_key = JWK.from_json(host.public_auth_key).export_to_pem()
194 jwt.decode(token, key=pem_public_key, algorithms=["RS256"])
195 except (jwt.InvalidTokenError, jwt.ExpiredSignatureError) as e:
196 logger.warning("invalid authentication token", error=str(e))
197 return HttpResponse(
198 "Invalid authentication token", status=401, content_type="text/plain"
199 )
201 start = time.time()
202 config = generate_config_yaml(host.pk)
203 duration = time.time() - start
205 if host.last_config_refresh != config:
206 logger.info(
207 "config changed",
208 host_id=host.id,
209 host_name=host.name,
210 duration=duration,
211 )
212 host.last_config_refresh = now()
213 host.save()
214 else:
215 logger.debug(
216 "config unchanged",
217 host_id=host.id,
218 host_name=host.name,
219 duration=duration,
220 )
222 response = HttpResponse(config, content_type="text/yaml")
223 response["X-Update-Interval"] = str(host.network.update_interval)
224 return response
225 except Exception as e:
226 logger.error("config generation failed", error=str(e))
227 return HttpResponse(
228 f"Failed to generate config: {str(e)}",
229 status=500,
230 content_type="text/plain",
231 )
234@api.post("/cleanup-ephemeral")
235def cleanup_ephemeral_hosts(request: HttpRequest):
236 bearer_token = request.headers.get("Authorization")
237 if not bearer_token:
238 logger.warning("cleanup request missing authorization")
239 return HttpResponse(status=403, content="Authorization bearer token missing")
241 token = bearer_token.split("Bearer ")[1]
242 try:
243 data = jwt.decode(
244 token, algorithms=["RS256"], options={"verify_signature": False}
245 )
246 try:
247 host = get_object_or_404(Host, public_auth_kid=data["kid"])
248 except Http404:
249 logger.warning("host not found for token kid", kid=data.get("kid"))
250 return HttpResponse(
251 "Host not found for authentication token",
252 status=401,
253 content_type="text/plain",
254 )
256 try:
257 pem_public_key = JWK.from_json(host.public_auth_key).export_to_pem()
258 jwt.decode(token, key=pem_public_key, algorithms=["RS256"])
259 except (jwt.InvalidTokenError, jwt.ExpiredSignatureError) as e:
260 logger.warning("invalid authentication token", error=str(e))
261 return HttpResponse(
262 "Invalid authentication token", status=401, content_type="text/plain"
263 )
265 cutoff = timezone.now() - timedelta(minutes=10)
266 stale_hosts = Host.objects.filter(
267 network=host.network, is_ephemeral=True, last_config_refresh__lt=cutoff
268 )
269 count = 0
270 for stale_host in stale_hosts:
271 logger.info(
272 "removing stale ephemeral host",
273 host_id=stale_host.id,
274 host_name=stale_host.name,
275 network=stale_host.network.name,
276 last_refresh=stale_host.last_config_refresh,
277 )
278 stale_host.delete()
279 count += 1
281 return {"removed_count": count}
283 except Exception as e:
284 logger.error("cleanup failed", error=str(e))
285 return HttpResponse(
286 f"Failed to clean up ephemeral hosts: {str(e)}",
287 status=500,
288 content_type="text/plain",
289 )
292@api.post("/networks", response=schemas.NetworkResponse, auth=keycloak_auth)
293def create_network_endpoint(request: HttpRequest, data: schemas.NetworkCreate):
294 network = create_network(
295 network_name=data.name, network_cidr=data.cidr, user=request.user
296 )
297 return {"id": network.id, "name": network.name, "cidr": network.cidr}
300@api.get("/networks", auth=keycloak_auth)
301def list_networks(request: HttpRequest):
302 if request.user.is_superuser:
303 networks = Network.objects.all()
304 else:
305 networks = Network.objects.filter(
306 memberships__user=request.user,
307 memberships__role=NetworkMembership.Role.ADMIN,
308 )
309 return [
310 {"id": network.id, "name": network.name, "cidr": network.cidr}
311 for network in networks
312 ]
315@api.delete("/networks/{name}", auth=keycloak_auth)
316def delete_network(request: HttpRequest, name: str):
317 try:
318 if not request.user.is_superuser:
319 network = Network.objects.filter(
320 memberships__user=request.user,
321 memberships__role=NetworkMembership.Role.ADMIN,
322 ).get(name=name)
323 else:
324 network = Network.objects.get(name=name)
325 network.delete()
326 return {"message": f"Network {name} deleted"}
327 except Network.DoesNotExist:
328 return HttpResponse(status=404, content=f"Network {name} not found")
331@api.post("/templates", response=schemas.TemplateResponse, auth=keycloak_auth)
332def create_template_endpoint(request: HttpRequest, data: schemas.TemplateCreate):
333 try:
334 network = Network.objects.get(name=data.network_name)
335 except Network.DoesNotExist:
336 return HttpResponse(
337 status=404, content=f"Network {data.network_name} not found"
338 )
339 if not request.user.is_superuser:
340 if not network.memberships.filter(
341 user=request.user, role=NetworkMembership.Role.ADMIN
342 ).exists():
343 return HttpResponse(status=403, content="Permission denied")
345 template = create_template(
346 name=data.name,
347 network_name=data.network_name,
348 is_lighthouse=data.is_lighthouse,
349 is_relay=data.is_relay,
350 use_relay=data.use_relay,
351 )
352 return {
353 "id": template.id,
354 "name": template.name,
355 "enrollment_key": template.enrollment_key,
356 }
359@api.delete("/templates/{name}", auth=keycloak_auth)
360def delete_template(request: HttpRequest, name: str):
361 try:
362 if not request.user.is_superuser:
363 template = Template.objects.filter(
364 network__memberships__user=request.user,
365 network__memberships__role=NetworkMembership.Role.ADMIN,
366 ).get(name=name)
367 else:
368 template = Template.objects.get(name=name)
369 template.delete()
370 return {"message": f"Template {name} deleted"}
371 except Template.DoesNotExist:
372 return HttpResponse(status=404, content=f"Template {name} not found")
375@api.get("/templates/{name}/token", auth=keycloak_auth)
376def get_template_token(request: HttpRequest, name: str):
377 try:
378 if not request.user.is_superuser:
379 template = Template.objects.filter(
380 network__memberships__user=request.user,
381 network__memberships__role=NetworkMembership.Role.ADMIN,
382 ).get(name=name)
383 else:
384 template = Template.objects.get(name=name)
385 return generate_enrollment_token(template)
386 except Template.DoesNotExist:
387 return HttpResponse(status=404, content=f"Template {name} not found")
390@api.delete("/hosts/{name}", auth=keycloak_auth)
391def delete_host(request: HttpRequest, name: str):
392 try:
393 if not request.user.is_superuser:
394 host = Host.objects.filter(
395 network__memberships__user=request.user,
396 network__memberships__role=NetworkMembership.Role.ADMIN,
397 ).get(name=name)
398 else:
399 host = Host.objects.get(name=name)
400 host.delete()
401 return {"message": f"Host {name} deleted"}
402 except Host.DoesNotExist:
403 return HttpResponse(status=404, content=f"Host {name} not found")