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

1import json 

2import time 

3from datetime import timedelta 

4from typing import Optional 

5 

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 

19 

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) 

30 

31logger = structlog.get_logger(__name__) 

32 

33User = get_user_model() 

34 

35 

36class KeycloakAuthBearer(HttpBearer): 

37 def __init__(self): 

38 super().__init__() 

39 self.jwks = None 

40 

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 

47 

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 

58 

59 if not public_key: 

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

61 return None 

62 

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 

72 

73 email = data.get("email") 

74 if not email: 

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

76 return None 

77 

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

79 if not user: 

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

81 return None 

82 

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 

92 

93 

94keycloak_auth = KeycloakAuthBearer() 

95 

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

97 

98 

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

104 

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 ) 

109 

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 ) 

119 

120 if binary_name not in valid_binaries: 

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

122 

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 ) 

128 

129 return FileResponse( 

130 open(binary_path, "rb"), 

131 as_attachment=True, 

132 filename=binary_name, 

133 content_type="application/octet-stream", 

134 ) 

135 

136 

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 ) 

146 

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 

168 

169 

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

176 

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 ) 

191 

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 ) 

200 

201 start = time.time() 

202 config = generate_config_yaml(host.pk) 

203 duration = time.time() - start 

204 

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 ) 

221 

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 ) 

232 

233 

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

240 

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 ) 

255 

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 ) 

264 

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 

280 

281 return {"removed_count": count} 

282 

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 ) 

290 

291 

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} 

298 

299 

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 ] 

313 

314 

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

329 

330 

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

344 

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 } 

357 

358 

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

373 

374 

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

388 

389 

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