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

180 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-04-04 09:39 +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.db import transaction 

11from django.http import FileResponse, Http404, HttpRequest, HttpResponse 

12from django.shortcuts import get_object_or_404 

13from django.utils import timezone 

14from django.utils.timezone import now 

15from jwcrypto.jwk import JWK 

16from ninja import NinjaAPI 

17from ninja.security import HttpBearer 

18 

19from meshadmin.server.assets import asset_path 

20from meshadmin.server.networks import schemas 

21from meshadmin.server.networks.models import Host, Network, NetworkMembership, Template 

22from meshadmin.server.networks.services import ( 

23 create_network, 

24 create_template, 

25 enrollment, 

26 generate_config_yaml, 

27) 

28 

29logger = structlog.get_logger(__name__) 

30 

31 

32class KeycloakAuthBearer(HttpBearer): 

33 def __init__(self): 

34 super().__init__() 

35 self.jwks = None 

36 

37 def get_keycloak_public_key(self): 

38 if not self.jwks: 

39 response = requests.get(settings.KEYCLOAK_CERTS_URL) 

40 response.raise_for_status() 

41 self.jwks = response.json() 

42 return self.jwks 

43 

44 def authenticate(self, request: HttpRequest, token: str) -> Optional[str]: 

45 try: 

46 unverified_headers = jwt.get_unverified_header(token) 

47 kid = unverified_headers["kid"] 

48 jwks = self.get_keycloak_public_key() 

49 public_key = None 

50 for key in jwks["keys"]: 

51 if key["kid"] == kid: 

52 public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key)) 

53 break 

54 

55 if not public_key: 

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

57 return None 

58 

59 data = jwt.decode( 

60 token, 

61 public_key, 

62 algorithms=["RS256"], 

63 options={"verify_signature": True}, 

64 issuer=settings.KEYCLOAK_ISSUER, 

65 ) 

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

67 return None 

68 return token 

69 except ( 

70 jwt.InvalidTokenError, 

71 jwt.ExpiredSignatureError, 

72 requests.RequestException, 

73 ) as e: 

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

75 return None 

76 

77 

78keycloak_auth = KeycloakAuthBearer() 

79 

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

81 

82 

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

84@transaction.atomic 

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

86 logger.info( 

87 "enrollment request received", 

88 enrollment_key=client_enrollment.enrollment_key, 

89 preferred_hostname=client_enrollment.preferred_hostname, 

90 public_ip=client_enrollment.public_ip, 

91 ) 

92 

93 try: 

94 host = enrollment( 

95 client_enrollment.enrollment_key, 

96 client_enrollment.public_auth_key, 

97 client_enrollment.enroll_on_existence, 

98 client_enrollment.public_ip, 

99 client_enrollment.preferred_hostname, 

100 client_enrollment.public_net_key, 

101 ) 

102 logger.info( 

103 "host enrolled successfully", 

104 host_id=host.id, 

105 network_name=host.network.name, 

106 hostname=host.name, 

107 ) 

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

109 except Exception as e: 

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

111 raise 

112 

113 

114@api.get("/config") 

115def get_config(request: HttpRequest): 

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

117 if not bearer_token: 

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

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

120 

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

122 try: 

123 data = jwt.decode( 

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

125 ) 

126 try: 

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

128 except Http404: 

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

130 return HttpResponse( 

131 "Host not found for authentication token", 

132 status=401, 

133 content_type="text/plain", 

134 ) 

135 

136 try: 

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

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

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

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

141 return HttpResponse( 

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

143 ) 

144 

145 start = time.time() 

146 config = generate_config_yaml(host.pk) 

147 duration = time.time() - start 

148 

149 if host.last_config_refresh != config: 

150 logger.info( 

151 "config changed", 

152 host_id=host.id, 

153 host_name=host.name, 

154 duration=duration, 

155 ) 

156 host.last_config_refresh = now() 

157 host.save() 

158 else: 

159 logger.debug( 

160 "config unchanged", 

161 host_id=host.id, 

162 host_name=host.name, 

163 duration=duration, 

164 ) 

165 

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

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

168 return response 

169 except Exception as e: 

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

171 return HttpResponse( 

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

173 status=500, 

174 content_type="text/plain", 

175 ) 

176 

177 

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

179def cleanup_ephemeral_hosts(request: HttpRequest): 

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

181 if not bearer_token: 

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

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

184 

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

186 try: 

187 data = jwt.decode( 

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

189 ) 

190 try: 

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

192 except Http404: 

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

194 return HttpResponse( 

195 "Host not found for authentication token", 

196 status=401, 

197 content_type="text/plain", 

198 ) 

199 

200 try: 

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

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

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

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

205 return HttpResponse( 

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

207 ) 

208 

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

210 stale_hosts = Host.objects.filter( 

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

212 ) 

213 count = 0 

214 for stale_host in stale_hosts: 

215 logger.info( 

216 "removing stale ephemeral host", 

217 host_id=stale_host.id, 

218 host_name=stale_host.name, 

219 network=stale_host.network.name, 

220 last_refresh=stale_host.last_config_refresh, 

221 ) 

222 stale_host.delete() 

223 count += 1 

224 

225 return {"removed_count": count} 

226 

227 except Exception as e: 

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

229 return HttpResponse( 

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

231 status=500, 

232 content_type="text/plain", 

233 ) 

234 

235 

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

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

238 network = create_network( 

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

240 ) 

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

242 

243 

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

245def list_networks(request: HttpRequest): 

246 networks = Network.objects.filter( 

247 memberships__user=request.user, memberships__role=NetworkMembership.Role.ADMIN 

248 ) 

249 return [ 

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

251 for network in networks 

252 ] 

253 

254 

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

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

257 try: 

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

259 network.delete() 

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

261 except Network.DoesNotExist: 

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

263 

264 

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

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

267 template = create_template( 

268 name=data.name, 

269 network_name=data.network_name, 

270 is_lighthouse=data.is_lighthouse, 

271 is_relay=data.is_relay, 

272 use_relay=data.use_relay, 

273 ) 

274 return { 

275 "id": template.id, 

276 "name": template.name, 

277 "enrollment_key": template.enrollment_key, 

278 } 

279 

280 

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

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

283 try: 

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

285 template.delete() 

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

287 except Template.DoesNotExist: 

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

289 

290 

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

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

293 try: 

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

295 host.delete() 

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

297 except Host.DoesNotExist: 

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

299 

300 

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

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

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

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

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

306 

307 if os_name not in valid_os: 

308 return HttpResponse( 

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

310 ) 

311 

312 if ( 

313 os_name not in valid_architectures 

314 or architecture not in valid_architectures[os_name] 

315 ): 

316 return HttpResponse( 

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

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

319 status=400, 

320 ) 

321 

322 if binary_name not in valid_binaries: 

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

324 

325 binary_path = asset_path / os_name / architecture / binary_name 

326 if not binary_path.exists(): 

327 return HttpResponse( 

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

329 ) 

330 

331 return FileResponse( 

332 open(binary_path, "rb"), 

333 as_attachment=True, 

334 filename=binary_name, 

335 content_type="application/octet-stream", 

336 )