Coverage for src/meshadmin/server/networks/api.py: 76%
180 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-04 09:21 +0200
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-04 09:21 +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.db import transaction
11from django.http import FileResponse, Http404, HttpRequest, HttpResponse
12from django.shortcuts import get_object_or_404
13from django.utils import timezone
14from django.utils.timezone import now
15from jwcrypto.jwk import JWK
16from ninja import NinjaAPI
17from ninja.security import HttpBearer
19from meshadmin.server.assets import asset_path
20from meshadmin.server.networks import schemas
21from meshadmin.server.networks.models import Host, Network, NetworkMembership, Template
22from meshadmin.server.networks.services import (
23 create_network,
24 create_template,
25 enrollment,
26 generate_config_yaml,
27)
29logger = structlog.get_logger(__name__)
32class KeycloakAuthBearer(HttpBearer):
33 def __init__(self):
34 super().__init__()
35 self.jwks = None
37 def get_keycloak_public_key(self):
38 if not self.jwks:
39 response = requests.get(settings.KEYCLOAK_CERTS_URL)
40 response.raise_for_status()
41 self.jwks = response.json()
42 return self.jwks
44 def authenticate(self, request: HttpRequest, token: str) -> Optional[str]:
45 try:
46 unverified_headers = jwt.get_unverified_header(token)
47 kid = unverified_headers["kid"]
48 jwks = self.get_keycloak_public_key()
49 public_key = None
50 for key in jwks["keys"]:
51 if key["kid"] == kid:
52 public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key))
53 break
55 if not public_key:
56 logger.error("No matching public key found")
57 return None
59 data = jwt.decode(
60 token,
61 public_key,
62 algorithms=["RS256"],
63 options={"verify_signature": True},
64 issuer=settings.KEYCLOAK_ISSUER,
65 )
66 if data.get("azp") != settings.KEYCLOAK_ADMIN_CLIENT:
67 return None
68 return token
69 except (
70 jwt.InvalidTokenError,
71 jwt.ExpiredSignatureError,
72 requests.RequestException,
73 ) as e:
74 logger.error("Token validation failed", error=str(e))
75 return None
78keycloak_auth = KeycloakAuthBearer()
80api = NinjaAPI(title="MeshAdmin API")
83@api.post("/enroll", url_name="enroll", auth=None)
84@transaction.atomic
85def enroll(request: HttpRequest, client_enrollment: schemas.ClientEnrollment):
86 logger.info(
87 "enrollment request received",
88 enrollment_key=client_enrollment.enrollment_key,
89 preferred_hostname=client_enrollment.preferred_hostname,
90 public_ip=client_enrollment.public_ip,
91 )
93 try:
94 host = enrollment(
95 client_enrollment.enrollment_key,
96 client_enrollment.public_auth_key,
97 client_enrollment.enroll_on_existence,
98 client_enrollment.public_ip,
99 client_enrollment.preferred_hostname,
100 client_enrollment.public_net_key,
101 )
102 logger.info(
103 "host enrolled successfully",
104 host_id=host.id,
105 network_name=host.network.name,
106 hostname=host.name,
107 )
108 return HttpResponse(f"Enrolled host {host.id} in network {host.network.name}")
109 except Exception as e:
110 logger.error("enrollment failed", error=str(e))
111 raise
114@api.get("/config")
115def get_config(request: HttpRequest):
116 bearer_token = request.headers.get("Authorization")
117 if not bearer_token:
118 logger.warning("config request missing authorization")
119 return HttpResponse(status=403, content="Authorization bearer token missing")
121 token = bearer_token.split("Bearer ")[1]
122 try:
123 data = jwt.decode(
124 token, algorithms=["RS256"], options={"verify_signature": False}
125 )
126 try:
127 host = get_object_or_404(Host, public_auth_kid=data["kid"])
128 except Http404:
129 logger.warning("host not found for token kid", kid=data.get("kid"))
130 return HttpResponse(
131 "Host not found for authentication token",
132 status=401,
133 content_type="text/plain",
134 )
136 try:
137 pem_public_key = JWK.from_json(host.public_auth_key).export_to_pem()
138 jwt.decode(token, key=pem_public_key, algorithms=["RS256"])
139 except (jwt.InvalidTokenError, jwt.ExpiredSignatureError) as e:
140 logger.warning("invalid authentication token", error=str(e))
141 return HttpResponse(
142 "Invalid authentication token", status=401, content_type="text/plain"
143 )
145 start = time.time()
146 config = generate_config_yaml(host.pk)
147 duration = time.time() - start
149 if host.last_config_refresh != config:
150 logger.info(
151 "config changed",
152 host_id=host.id,
153 host_name=host.name,
154 duration=duration,
155 )
156 host.last_config_refresh = now()
157 host.save()
158 else:
159 logger.debug(
160 "config unchanged",
161 host_id=host.id,
162 host_name=host.name,
163 duration=duration,
164 )
166 response = HttpResponse(config, content_type="text/yaml")
167 response["X-Update-Interval"] = str(host.network.update_interval)
168 return response
169 except Exception as e:
170 logger.error("config generation failed", error=str(e))
171 return HttpResponse(
172 f"Failed to generate config: {str(e)}",
173 status=500,
174 content_type="text/plain",
175 )
178@api.post("/cleanup-ephemeral")
179def cleanup_ephemeral_hosts(request: HttpRequest):
180 bearer_token = request.headers.get("Authorization")
181 if not bearer_token:
182 logger.warning("cleanup request missing authorization")
183 return HttpResponse(status=403, content="Authorization bearer token missing")
185 token = bearer_token.split("Bearer ")[1]
186 try:
187 data = jwt.decode(
188 token, algorithms=["RS256"], options={"verify_signature": False}
189 )
190 try:
191 host = get_object_or_404(Host, public_auth_kid=data["kid"])
192 except Http404:
193 logger.warning("host not found for token kid", kid=data.get("kid"))
194 return HttpResponse(
195 "Host not found for authentication token",
196 status=401,
197 content_type="text/plain",
198 )
200 try:
201 pem_public_key = JWK.from_json(host.public_auth_key).export_to_pem()
202 jwt.decode(token, key=pem_public_key, algorithms=["RS256"])
203 except (jwt.InvalidTokenError, jwt.ExpiredSignatureError) as e:
204 logger.warning("invalid authentication token", error=str(e))
205 return HttpResponse(
206 "Invalid authentication token", status=401, content_type="text/plain"
207 )
209 cutoff = timezone.now() - timedelta(minutes=10)
210 stale_hosts = Host.objects.filter(
211 network=host.network, is_ephemeral=True, last_config_refresh__lt=cutoff
212 )
213 count = 0
214 for stale_host in stale_hosts:
215 logger.info(
216 "removing stale ephemeral host",
217 host_id=stale_host.id,
218 host_name=stale_host.name,
219 network=stale_host.network.name,
220 last_refresh=stale_host.last_config_refresh,
221 )
222 stale_host.delete()
223 count += 1
225 return {"removed_count": count}
227 except Exception as e:
228 logger.error("cleanup failed", error=str(e))
229 return HttpResponse(
230 f"Failed to clean up ephemeral hosts: {str(e)}",
231 status=500,
232 content_type="text/plain",
233 )
236@api.post("/networks", response=schemas.NetworkResponse, auth=keycloak_auth)
237def create_network_endpoint(request: HttpRequest, data: schemas.NetworkCreate):
238 network = create_network(
239 network_name=data.name, network_cidr=data.cidr, user=request.user
240 )
241 return {"id": network.id, "name": network.name, "cidr": network.cidr}
244@api.get("/networks", auth=keycloak_auth)
245def list_networks(request: HttpRequest):
246 networks = Network.objects.filter(
247 memberships__user=request.user, memberships__role=NetworkMembership.Role.ADMIN
248 )
249 return [
250 {"id": network.id, "name": network.name, "cidr": network.cidr}
251 for network in networks
252 ]
255@api.delete("/networks/{name}", auth=keycloak_auth)
256def delete_network(request: HttpRequest, name: str):
257 try:
258 network = Network.objects.get(name=name)
259 network.delete()
260 return {"message": f"Network {name} deleted"}
261 except Network.DoesNotExist:
262 return HttpResponse(status=404, content=f"Network {name} not found")
265@api.post("/templates", response=schemas.TemplateResponse, auth=keycloak_auth)
266def create_template_endpoint(request: HttpRequest, data: schemas.TemplateCreate):
267 template = create_template(
268 name=data.name,
269 network_name=data.network_name,
270 is_lighthouse=data.is_lighthouse,
271 is_relay=data.is_relay,
272 use_relay=data.use_relay,
273 )
274 return {
275 "id": template.id,
276 "name": template.name,
277 "enrollment_key": template.enrollment_key,
278 }
281@api.delete("/templates/{name}", auth=keycloak_auth)
282def delete_template(request: HttpRequest, name: str):
283 try:
284 template = Template.objects.get(name=name)
285 template.delete()
286 return {"message": f"Template {name} deleted"}
287 except Template.DoesNotExist:
288 return HttpResponse(status=404, content=f"Template {name} not found")
291@api.delete("/hosts/{name}", auth=keycloak_auth)
292def delete_host(request: HttpRequest, name: str):
293 try:
294 host = Host.objects.get(name=name)
295 host.delete()
296 return {"message": f"Host {name} deleted"}
297 except Host.DoesNotExist:
298 return HttpResponse(status=404, content=f"Host {name} not found")
301@api.get("/nebula/download/{os_name}/{architecture}/{binary_name}")
302def download_nebula_binary(request, os_name: str, architecture: str, binary_name: str):
303 valid_os = ["Linux", "Darwin"]
304 valid_binaries = ["nebula", "nebula-cert"]
305 valid_architectures = {"Darwin": ["arm64"], "Linux": ["aarch64", "x86_64"]}
307 if os_name not in valid_os:
308 return HttpResponse(
309 f"Invalid OS: {os_name}. Only Linux and Darwin are supported.", status=400
310 )
312 if (
313 os_name not in valid_architectures
314 or architecture not in valid_architectures[os_name]
315 ):
316 return HttpResponse(
317 f"Invalid architecture: {architecture} for {os_name}. "
318 f"Supported architectures for {os_name}: {valid_architectures.get(os_name, [])}",
319 status=400,
320 )
322 if binary_name not in valid_binaries:
323 return HttpResponse(f"Invalid binary name: {binary_name}", status=400)
325 binary_path = asset_path / os_name / architecture / binary_name
326 if not binary_path.exists():
327 return HttpResponse(
328 f"Binary not found: {binary_name} for {os_name}/{architecture}", status=404
329 )
331 return FileResponse(
332 open(binary_path, "rb"),
333 as_attachment=True,
334 filename=binary_name,
335 content_type="application/octet-stream",
336 )