Coverage for src/meshadmin/server/networks/api.py: 67%

241 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-07 19:26 +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 for kid in JWKS", kid=kid) 

67 return None 

68 

69 data = jwt.decode( 

70 token, 

71 public_key, 

72 algorithms=["RS256"], 

73 issuer=settings.KEYCLOAK_ISSUER, 

74 options={"verify_aud": False}, 

75 ) 

76 # Client Credential Flow 

77 if data.get("azp") in settings.KEYCLOAK_ALLOWED_CLIENTS: 

78 request.is_client_credential_auth = True 

79 return token 

80 

81 logger.info("Client credential flow not allowed", azp=data.get("azp")) 

82 

83 if data.get("azp") != settings.KEYCLOAK_ADMIN_CLIENT: 

84 return None 

85 

86 email = data.get("email") 

87 if not email: 

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

89 return None 

90 

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

92 if not user: 

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

94 return None 

95 

96 request.user = user 

97 return token 

98 except ( 

99 jwt.InvalidTokenError, 

100 jwt.ExpiredSignatureError, 

101 requests.RequestException, 

102 ) as e: 

103 logger.error("Token validation failed", error=str(e)) 

104 return None 

105 

106 

107keycloak_auth = KeycloakAuthBearer() 

108 

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

110 

111 

112@api.get("/nebula/download/{os_name}/{architecture}/{binary_name}") 

113def download_nebula_binary(request, os_name: str, architecture: str, binary_name: str): 

114 valid_os = ["Linux", "Darwin"] 

115 valid_binaries = ["nebula", "nebula-cert"] 

116 valid_architectures = {"Darwin": ["arm64"], "Linux": ["aarch64", "x86_64"]} 

117 

118 if os_name not in valid_os: 

119 return HttpResponse( 

120 f"Invalid OS: {os_name}. Only Linux and Darwin are supported.", status=400 

121 ) 

122 

123 if ( 

124 os_name not in valid_architectures 

125 or architecture not in valid_architectures[os_name] 

126 ): 

127 return HttpResponse( 

128 f"Invalid architecture: {architecture} for {os_name}. " 

129 f"Supported architectures for {os_name}: {valid_architectures.get(os_name, [])}", 

130 status=400, 

131 ) 

132 

133 if binary_name not in valid_binaries: 

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

135 

136 binary_path = asset_path / os_name / architecture / binary_name 

137 if not binary_path.exists(): 

138 return HttpResponse( 

139 f"Binary not found: {binary_name} for {os_name}/{architecture}", status=404 

140 ) 

141 

142 return FileResponse( 

143 open(binary_path, "rb"), 

144 as_attachment=True, 

145 filename=binary_name, 

146 content_type="application/octet-stream", 

147 ) 

148 

149 

150@api.post("/enroll", url_name="enroll", auth=None) 

151@transaction.atomic 

152def enroll(request: HttpRequest, client_enrollment: schemas.ClientEnrollment): 

153 logger.info( 

154 "enrollment request received", 

155 enrollment_key=client_enrollment.enrollment_key, 

156 preferred_hostname=client_enrollment.preferred_hostname, 

157 public_ip=client_enrollment.public_ip, 

158 ) 

159 

160 try: 

161 host = enrollment( 

162 client_enrollment.enrollment_key, 

163 client_enrollment.public_auth_key, 

164 client_enrollment.enroll_on_existence, 

165 client_enrollment.public_ip, 

166 client_enrollment.preferred_hostname, 

167 client_enrollment.public_net_key, 

168 client_enrollment.interface, 

169 ) 

170 logger.info( 

171 "host enrolled successfully", 

172 host_id=host.id, 

173 network_name=host.network.name, 

174 hostname=host.name, 

175 interface=host.interface, 

176 ) 

177 return HttpResponse(f"Enrolled host {host.id} in network {host.network.name}") 

178 except Exception as e: 

179 logger.error("enrollment failed", error=str(e)) 

180 raise 

181 

182 

183@api.get("/config") 

184def get_config(request: HttpRequest): 

185 bearer_token = request.headers.get("Authorization") 

186 if not bearer_token: 

187 logger.warning("config request missing authorization") 

188 return HttpResponse(status=403, content="Authorization bearer token missing") 

189 

190 token = bearer_token.split("Bearer ")[1] 

191 try: 

192 data = jwt.decode( 

193 token, algorithms=["RS256"], options={"verify_signature": False} 

194 ) 

195 try: 

196 host = get_object_or_404(Host, public_auth_kid=data["kid"]) 

197 except Http404: 

198 logger.warning("host not found for token kid", kid=data.get("kid")) 

199 return HttpResponse( 

200 "Host not found for authentication token", 

201 status=401, 

202 content_type="text/plain", 

203 ) 

204 

205 try: 

206 pem_public_key = JWK.from_json(host.public_auth_key).export_to_pem() 

207 jwt.decode(token, key=pem_public_key, algorithms=["RS256"]) 

208 except (jwt.InvalidTokenError, jwt.ExpiredSignatureError) as e: 

209 logger.warning("invalid authentication token", error=str(e)) 

210 return HttpResponse( 

211 "Invalid authentication token", status=401, content_type="text/plain" 

212 ) 

213 

214 start = time.time() 

215 config = generate_config_yaml(host.pk) 

216 duration = time.time() - start 

217 

218 if host.last_config_refresh != config: 

219 logger.info( 

220 "config changed", 

221 host_id=host.id, 

222 host_name=host.name, 

223 duration=duration, 

224 ) 

225 host.last_config_refresh = now() 

226 host.save() 

227 else: 

228 logger.debug( 

229 "config unchanged", 

230 host_id=host.id, 

231 host_name=host.name, 

232 duration=duration, 

233 ) 

234 

235 cli_version = request.headers.get("X-Meshadmin-Version") 

236 if cli_version and host.cli_version != cli_version: 

237 logger.info( 

238 "Mismatch detected. Updating the version in the database", 

239 host_id=host.id, 

240 host_name=host.name, 

241 cli_version=cli_version, 

242 ) 

243 host.cli_version = cli_version 

244 host.save() 

245 

246 response = HttpResponse(config, content_type="text/yaml") 

247 response["X-Update-Interval"] = str(host.network.update_interval) 

248 if host.upgrade_requested: 

249 logger.info( 

250 "upgrade requested", 

251 host_id=host.id, 

252 host_name=host.name, 

253 cli_version=cli_version, 

254 ) 

255 response["X-Upgrade-Requested"] = "true" 

256 host.upgrade_requested = False 

257 host.save() 

258 return response 

259 except Exception as e: 

260 logger.error("config generation failed", error=str(e)) 

261 return HttpResponse( 

262 f"Failed to generate config: {str(e)}", 

263 status=500, 

264 content_type="text/plain", 

265 ) 

266 

267 

268@api.post("/cleanup-ephemeral") 

269def cleanup_ephemeral_hosts(request: HttpRequest): 

270 bearer_token = request.headers.get("Authorization") 

271 if not bearer_token: 

272 logger.warning("cleanup request missing authorization") 

273 return HttpResponse(status=403, content="Authorization bearer token missing") 

274 

275 token = bearer_token.split("Bearer ")[1] 

276 try: 

277 data = jwt.decode( 

278 token, algorithms=["RS256"], options={"verify_signature": False} 

279 ) 

280 try: 

281 host = get_object_or_404(Host, public_auth_kid=data["kid"]) 

282 except Http404: 

283 logger.warning("host not found for token kid", kid=data.get("kid")) 

284 return HttpResponse( 

285 "Host not found for authentication token", 

286 status=401, 

287 content_type="text/plain", 

288 ) 

289 

290 try: 

291 pem_public_key = JWK.from_json(host.public_auth_key).export_to_pem() 

292 jwt.decode(token, key=pem_public_key, algorithms=["RS256"]) 

293 except (jwt.InvalidTokenError, jwt.ExpiredSignatureError) as e: 

294 logger.warning("invalid authentication token", error=str(e)) 

295 return HttpResponse( 

296 "Invalid authentication token", status=401, content_type="text/plain" 

297 ) 

298 

299 cutoff = timezone.now() - timedelta(minutes=10) 

300 stale_hosts = Host.objects.filter( 

301 network=host.network, is_ephemeral=True, last_config_refresh__lt=cutoff 

302 ) 

303 count = 0 

304 for stale_host in stale_hosts: 

305 logger.info( 

306 "removing stale ephemeral host", 

307 host_id=stale_host.id, 

308 host_name=stale_host.name, 

309 network=stale_host.network.name, 

310 last_refresh=stale_host.last_config_refresh, 

311 ) 

312 stale_host.delete() 

313 count += 1 

314 

315 return {"removed_count": count} 

316 

317 except Exception as e: 

318 logger.error("cleanup failed", error=str(e)) 

319 return HttpResponse( 

320 f"Failed to clean up ephemeral hosts: {str(e)}", 

321 status=500, 

322 content_type="text/plain", 

323 ) 

324 

325 

326@api.post("/networks", response=schemas.NetworkResponse, auth=keycloak_auth) 

327def create_network_endpoint(request: HttpRequest, data: schemas.NetworkCreate): 

328 network = create_network( 

329 network_name=data.name, network_cidr=data.cidr, user=request.user 

330 ) 

331 return {"id": network.id, "name": network.name, "cidr": network.cidr} 

332 

333 

334@api.get("/networks", auth=keycloak_auth) 

335def list_networks(request: HttpRequest): 

336 if request.user.is_superuser: 

337 networks = Network.objects.all() 

338 else: 

339 networks = Network.objects.filter( 

340 memberships__user=request.user, 

341 memberships__role=NetworkMembership.Role.ADMIN, 

342 ) 

343 return [ 

344 {"id": network.id, "name": network.name, "cidr": network.cidr} 

345 for network in networks 

346 ] 

347 

348 

349@api.delete("/networks/{name}", auth=keycloak_auth) 

350def delete_network(request: HttpRequest, name: str): 

351 try: 

352 if not request.user.is_superuser: 

353 network = Network.objects.filter( 

354 memberships__user=request.user, 

355 memberships__role=NetworkMembership.Role.ADMIN, 

356 ).get(name=name) 

357 else: 

358 network = Network.objects.get(name=name) 

359 network.delete() 

360 return {"message": f"Network {name} deleted"} 

361 except Network.DoesNotExist: 

362 return HttpResponse(status=404, content=f"Network {name} not found") 

363 

364 

365@api.post("/templates", response=schemas.TemplateResponse, auth=keycloak_auth) 

366def create_template_endpoint(request: HttpRequest, data: schemas.TemplateCreate): 

367 try: 

368 network = Network.objects.get(name=data.network_name) 

369 except Network.DoesNotExist: 

370 return HttpResponse( 

371 status=404, content=f"Network {data.network_name} not found" 

372 ) 

373 if not request.user.is_superuser: 

374 if not network.memberships.filter( 

375 user=request.user, role=NetworkMembership.Role.ADMIN 

376 ).exists(): 

377 return HttpResponse(status=403, content="Permission denied") 

378 

379 template = create_template( 

380 name=data.name, 

381 network_name=data.network_name, 

382 is_lighthouse=data.is_lighthouse, 

383 is_relay=data.is_relay, 

384 use_relay=data.use_relay, 

385 ) 

386 return { 

387 "id": template.id, 

388 "name": template.name, 

389 "enrollment_key": template.enrollment_key, 

390 } 

391 

392 

393@api.delete("/templates/{name}", auth=keycloak_auth) 

394def delete_template(request: HttpRequest, name: str): 

395 try: 

396 if not request.user.is_superuser: 

397 template = Template.objects.filter( 

398 network__memberships__user=request.user, 

399 network__memberships__role=NetworkMembership.Role.ADMIN, 

400 ).get(name=name) 

401 else: 

402 template = Template.objects.get(name=name) 

403 template.delete() 

404 return {"message": f"Template {name} deleted"} 

405 except Template.DoesNotExist: 

406 return HttpResponse(status=404, content=f"Template {name} not found") 

407 

408 

409@api.get("/templates/{name}/token", auth=keycloak_auth) 

410def get_template_token(request: HttpRequest, name: str, ttl: int = None): 

411 try: 

412 is_client_auth = getattr(request, "is_client_credential_auth", False) 

413 if is_client_auth: 

414 logger.info("Authenticated with client credential flow", template_name=name) 

415 template = Template.objects.get(name=name) 

416 else: 

417 if not request.user.is_superuser: 

418 template = Template.objects.filter( 

419 network__memberships__user=request.user, 

420 network__memberships__role=NetworkMembership.Role.ADMIN, 

421 ).get(name=name) 

422 else: 

423 template = Template.objects.get(name=name) 

424 return { 

425 "token": generate_enrollment_token(template, ttl=ttl), 

426 "template_id": template.id, 

427 } 

428 except Template.DoesNotExist: 

429 return HttpResponse(status=404, content=f"Template {name} not found") 

430 

431 

432@api.delete("/hosts/{name}", auth=keycloak_auth) 

433def delete_host(request: HttpRequest, name: str): 

434 try: 

435 if not request.user.is_superuser: 

436 host = Host.objects.filter( 

437 network__memberships__user=request.user, 

438 network__memberships__role=NetworkMembership.Role.ADMIN, 

439 ).get(name=name) 

440 else: 

441 host = Host.objects.get(name=name) 

442 host.delete() 

443 return {"message": f"Host {name} deleted"} 

444 except Host.DoesNotExist: 

445 return HttpResponse(status=404, content=f"Host {name} not found") 

446 

447 

448@api.get("/test") 

449def test(request: HttpRequest): 

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