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

228 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-04-22 07: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") 

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 "New version detected. Updating the client 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 return response 

242 except Exception as e: 

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

244 return HttpResponse( 

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

246 status=500, 

247 content_type="text/plain", 

248 ) 

249 

250 

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

252def cleanup_ephemeral_hosts(request: HttpRequest): 

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

254 if not bearer_token: 

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

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

257 

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

259 try: 

260 data = jwt.decode( 

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

262 ) 

263 try: 

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

265 except Http404: 

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

267 return HttpResponse( 

268 "Host not found for authentication token", 

269 status=401, 

270 content_type="text/plain", 

271 ) 

272 

273 try: 

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

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

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

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

278 return HttpResponse( 

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

280 ) 

281 

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

283 stale_hosts = Host.objects.filter( 

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

285 ) 

286 count = 0 

287 for stale_host in stale_hosts: 

288 logger.info( 

289 "removing stale ephemeral host", 

290 host_id=stale_host.id, 

291 host_name=stale_host.name, 

292 network=stale_host.network.name, 

293 last_refresh=stale_host.last_config_refresh, 

294 ) 

295 stale_host.delete() 

296 count += 1 

297 

298 return {"removed_count": count} 

299 

300 except Exception as e: 

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

302 return HttpResponse( 

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

304 status=500, 

305 content_type="text/plain", 

306 ) 

307 

308 

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

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

311 network = create_network( 

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

313 ) 

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

315 

316 

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

318def list_networks(request: HttpRequest): 

319 if request.user.is_superuser: 

320 networks = Network.objects.all() 

321 else: 

322 networks = Network.objects.filter( 

323 memberships__user=request.user, 

324 memberships__role=NetworkMembership.Role.ADMIN, 

325 ) 

326 return [ 

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

328 for network in networks 

329 ] 

330 

331 

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

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

334 try: 

335 if not request.user.is_superuser: 

336 network = Network.objects.filter( 

337 memberships__user=request.user, 

338 memberships__role=NetworkMembership.Role.ADMIN, 

339 ).get(name=name) 

340 else: 

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

342 network.delete() 

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

344 except Network.DoesNotExist: 

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

346 

347 

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

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

350 try: 

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

352 except Network.DoesNotExist: 

353 return HttpResponse( 

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

355 ) 

356 if not request.user.is_superuser: 

357 if not network.memberships.filter( 

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

359 ).exists(): 

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

361 

362 template = create_template( 

363 name=data.name, 

364 network_name=data.network_name, 

365 is_lighthouse=data.is_lighthouse, 

366 is_relay=data.is_relay, 

367 use_relay=data.use_relay, 

368 ) 

369 return { 

370 "id": template.id, 

371 "name": template.name, 

372 "enrollment_key": template.enrollment_key, 

373 } 

374 

375 

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

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

378 try: 

379 if not request.user.is_superuser: 

380 template = Template.objects.filter( 

381 network__memberships__user=request.user, 

382 network__memberships__role=NetworkMembership.Role.ADMIN, 

383 ).get(name=name) 

384 else: 

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

386 template.delete() 

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

388 except Template.DoesNotExist: 

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

390 

391 

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

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

394 try: 

395 if not request.user.is_superuser: 

396 template = Template.objects.filter( 

397 network__memberships__user=request.user, 

398 network__memberships__role=NetworkMembership.Role.ADMIN, 

399 ).get(name=name) 

400 else: 

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

402 return { 

403 "token": generate_enrollment_token(template), 

404 "template_id": template.id, 

405 } 

406 except Template.DoesNotExist: 

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

408 

409 

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

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

412 try: 

413 if not request.user.is_superuser: 

414 host = Host.objects.filter( 

415 network__memberships__user=request.user, 

416 network__memberships__role=NetworkMembership.Role.ADMIN, 

417 ).get(name=name) 

418 else: 

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

420 host.delete() 

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

422 except Host.DoesNotExist: 

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

424 

425 

426@api.get("/test") 

427def test(request: HttpRequest): 

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