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

233 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-06 11:34 +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 cli_version = request.headers.get("X-Meshadmin-Version") 

229 if cli_version and host.cli_version != cli_version: 

230 logger.info( 

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

232 host_id=host.id, 

233 host_name=host.name, 

234 cli_version=cli_version, 

235 ) 

236 host.cli_version = cli_version 

237 host.save() 

238 

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

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

241 if host.upgrade_requested: 

242 logger.info( 

243 "upgrade requested", 

244 host_id=host.id, 

245 host_name=host.name, 

246 cli_version=cli_version, 

247 ) 

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

249 host.upgrade_requested = False 

250 host.save() 

251 return response 

252 except Exception as e: 

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

254 return HttpResponse( 

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

256 status=500, 

257 content_type="text/plain", 

258 ) 

259 

260 

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

262def cleanup_ephemeral_hosts(request: HttpRequest): 

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

264 if not bearer_token: 

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

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

267 

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

269 try: 

270 data = jwt.decode( 

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

272 ) 

273 try: 

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

275 except Http404: 

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

277 return HttpResponse( 

278 "Host not found for authentication token", 

279 status=401, 

280 content_type="text/plain", 

281 ) 

282 

283 try: 

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

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

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

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

288 return HttpResponse( 

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

290 ) 

291 

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

293 stale_hosts = Host.objects.filter( 

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

295 ) 

296 count = 0 

297 for stale_host in stale_hosts: 

298 logger.info( 

299 "removing stale ephemeral host", 

300 host_id=stale_host.id, 

301 host_name=stale_host.name, 

302 network=stale_host.network.name, 

303 last_refresh=stale_host.last_config_refresh, 

304 ) 

305 stale_host.delete() 

306 count += 1 

307 

308 return {"removed_count": count} 

309 

310 except Exception as e: 

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

312 return HttpResponse( 

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

314 status=500, 

315 content_type="text/plain", 

316 ) 

317 

318 

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

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

321 network = create_network( 

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

323 ) 

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

325 

326 

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

328def list_networks(request: HttpRequest): 

329 if request.user.is_superuser: 

330 networks = Network.objects.all() 

331 else: 

332 networks = Network.objects.filter( 

333 memberships__user=request.user, 

334 memberships__role=NetworkMembership.Role.ADMIN, 

335 ) 

336 return [ 

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

338 for network in networks 

339 ] 

340 

341 

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

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

344 try: 

345 if not request.user.is_superuser: 

346 network = Network.objects.filter( 

347 memberships__user=request.user, 

348 memberships__role=NetworkMembership.Role.ADMIN, 

349 ).get(name=name) 

350 else: 

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

352 network.delete() 

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

354 except Network.DoesNotExist: 

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

356 

357 

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

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

360 try: 

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

362 except Network.DoesNotExist: 

363 return HttpResponse( 

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

365 ) 

366 if not request.user.is_superuser: 

367 if not network.memberships.filter( 

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

369 ).exists(): 

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

371 

372 template = create_template( 

373 name=data.name, 

374 network_name=data.network_name, 

375 is_lighthouse=data.is_lighthouse, 

376 is_relay=data.is_relay, 

377 use_relay=data.use_relay, 

378 ) 

379 return { 

380 "id": template.id, 

381 "name": template.name, 

382 "enrollment_key": template.enrollment_key, 

383 } 

384 

385 

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

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

388 try: 

389 if not request.user.is_superuser: 

390 template = Template.objects.filter( 

391 network__memberships__user=request.user, 

392 network__memberships__role=NetworkMembership.Role.ADMIN, 

393 ).get(name=name) 

394 else: 

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

396 template.delete() 

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

398 except Template.DoesNotExist: 

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

400 

401 

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

403def get_template_token(request: HttpRequest, name: str): 

404 try: 

405 if not request.user.is_superuser: 

406 template = Template.objects.filter( 

407 network__memberships__user=request.user, 

408 network__memberships__role=NetworkMembership.Role.ADMIN, 

409 ).get(name=name) 

410 else: 

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

412 return { 

413 "token": generate_enrollment_token(template), 

414 "template_id": template.id, 

415 } 

416 except Template.DoesNotExist: 

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

418 

419 

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

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

422 try: 

423 if not request.user.is_superuser: 

424 host = Host.objects.filter( 

425 network__memberships__user=request.user, 

426 network__memberships__role=NetworkMembership.Role.ADMIN, 

427 ).get(name=name) 

428 else: 

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

430 host.delete() 

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

432 except Host.DoesNotExist: 

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

434 

435 

436@api.get("/test") 

437def test(request: HttpRequest): 

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