1 import json

2 import os

3 import time

4 from datetime import timedelta

5 from typing import Optional

6

7 import jwt

8 import requests

9 import structlog

10 from django.conf import settings

11 from django.contrib.auth import get_user_model

12 from django.db import transaction

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

14 from django.shortcuts import get_object_or_404

15 from django.utils import timezone

16 from django.utils.timezone import now

17 from jwcrypto.jwk import JWK

18 from ninja import NinjaAPI

19 from ninja.security import HttpBearer

20

21 from meshadmin.common import schemas

22 from meshadmin.server.assets import asset_path

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

24 from meshadmin.server.networks.services import (

25 create_network,

26 create_template,

27 enrollment,

28 generate_config_yaml,

29 generate_enrollment_token,

30)

31

32 logger = structlog.get_logger(__name__)

33

34 User = get_user_model()

35

36

37 class 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

100 keycloak_auth = KeycloakAuthBearer()

101

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

103

104

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

106 def 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

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

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

262 def 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)

320 def 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)

328 def 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)

343 def 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)

359 def 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)

387 def 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)

403 def 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)

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

437 def test(request: HttpRequest):

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