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

1import json 

2import os 

3import time 

4from datetime import timedelta 

5from typing import Optional 

6 

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 

20 

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) 

31 

32logger = structlog.get_logger(__name__) 

33 

34User = get_user_model() 

35 

36 

37class KeycloakAuthBearer(HttpBearer): 

38 def __init__(self): 

39 super().__init__() 

40 self.jwks = None 

41 

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 

48 

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 

55 

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 

64 

65 if not public_key: 

66 logger.error("No matching public key found") 

67 return None 

68 

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 

78 

79 email = data.get("email") 

80 if not email: 

81 logger.error("No email found in token") 

82 return None 

83 

84 user = User.objects.filter(email=email).first() 

85 if not user: 

86 logger.error("User not found", email=email) 

87 return None 

88 

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 

98 

99 

100keycloak_auth = KeycloakAuthBearer() 

101 

102api = NinjaAPI(title="MeshAdmin API") 

103 

104 

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"]} 

110 

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 ) 

115 

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 ) 

125 

126 if binary_name not in valid_binaries: 

127 return HttpResponse(f"Invalid binary name: {binary_name}", status=400) 

128 

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 ) 

134 

135 return FileResponse( 

136 open(binary_path, "rb"), 

137 as_attachment=True, 

138 filename=binary_name, 

139 content_type="application/octet-stream", 

140 ) 

141 

142 

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 ) 

152 

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 

174 

175 

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") 

182 

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 ) 

197 

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 ) 

206 

207 start = time.time() 

208 config = generate_config_yaml(host.pk) 

209 duration = time.time() - start 

210 

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 ) 

227 

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 ) 

238 

239 

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") 

246 

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 ) 

261 

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 ) 

270 

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 

286 

287 return {"removed_count": count} 

288 

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 ) 

296 

297 

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} 

304 

305 

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 ] 

319 

320 

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") 

335 

336 

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") 

350 

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 } 

363 

364 

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") 

379 

380 

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") 

397 

398 

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") 

413 

414 

415@api.get("/test") 

416def test(request: HttpRequest): 

417 return {"message": "Test endpoint"}