1 import json

2 from datetime import timedelta

3 from pathlib import Path

4

5 import pytest

6 from django.utils import timezone

7 from jwcrypto.jwk import JWK

8 from jwcrypto.jwt import JWT

9

10 from meshadmin.common import schemas

11 from meshadmin.common.utils import create_keys

12 from meshadmin.server.networks.models import Host, Template

13 from meshadmin.server.networks.services import (

14 create_template,

15 generate_enrollment_token,

16)

17

18

19@pytest.fixture

20 def keycloak_key():

21 key = JWK.generate(kty="RSA", size=2048, kid="test-key-id")

22 return {

23 "private_key": key,

24 "public_key": json.loads(key.export_public()),

25 "kid": "test-key-id",

26 }

27

28

29@pytest.fixture

30 def keycloak_auth_headers(mocker, keycloak_key, settings):

31 jwt_token = JWT(

32 header={"alg": "RS256", "kid": keycloak_key["kid"]},

33 claims={

34 "exp": 9999999999,

35 "iat": 1741328648,

36 "iss": settings.KEYCLOAK_ISSUER,

37 "azp": settings.KEYCLOAK_ADMIN_CLIENT,

38 "typ": "Bearer",

39 "email": "test@example.com",

40 },

41 )

42 jwt_token.make_signed_token(keycloak_key["private_key"])

43 mock_get = mocker.patch("requests.get")

44 mock_get.return_value.json.return_value = {"keys": [keycloak_key["public_key"]]}

45 mock_get.return_value.raise_for_status.return_value = None

46

47 mocker.patch(

48 "meshadmin.server.networks.api.KeycloakAuthBearer.get_keycloak_public_key",

49 return_value={"keys": [keycloak_key["public_key"]]},

50 )

51 return {"HTTP_AUTHORIZATION": f"Bearer {jwt_token.serialize()}"}

52

53

54 def test_template_endpoints(db, client, test_network, keycloak_auth_headers):

55 network = test_network(name="test_network", cidr="100.100.64.0/24")

56 template_data = schemas.TemplateCreate(

57 name="test_template",

58 network_name=network.name,

59 is_lighthouse=True,

60 is_relay=True,

61 use_relay=False,

62 )

63 response = client.post(

64 "/api/v1/templates",

65 data=template_data.model_dump_json(),

66 content_type="application/json",

67 **keycloak_auth_headers,

68 )

69 assert response.status_code == 200

70 response_data = response.json()

71 assert response_data["name"] == "test_template"

72 assert "enrollment_key" in response_data

73

74 # Delete template

75 response = client.delete(

76 "/api/v1/templates/test_template",

77 **keycloak_auth_headers,

78 )

79 assert response.status_code == 200

80 assert not Template.objects.filter(name="test_template").exists()

81

82

83 def test_host_endpoints(client, test_network, keycloak_auth_headers):

84 network = test_network(name="test_network", cidr="100.100.64.0/24")

85 # First create and enroll a host

86 template = create_template(

87 "host_template",

88 network.name,

89 is_lighthouse=False,

90 is_relay=False,

91 )

92 token = generate_enrollment_token(template)

93 auth_key = JWK.generate(kty="RSA", size=2048)

94 _, public_net_key = create_keys()

95 enrollment_data = schemas.ClientEnrollment(

96 enrollment_key=token,

97 public_net_key=public_net_key,

98 public_auth_key=auth_key.export_public(),

99 preferred_hostname="test-host",

100 public_ip="127.0.0.1",

101 enroll_on_existence=False,

102 )

103 response = client.post(

104 "/api/v1/enroll",

105 data=enrollment_data.model_dump_json(),

106 content_type="application/json",

107 )

108 assert response.status_code == 200

109

110 # Delete host

111 response = client.delete(

112 "/api/v1/hosts/test-host",

113 **keycloak_auth_headers,

114 )

115 assert response.status_code == 200

116 assert not Host.objects.filter(name="test-host").exists()

117

118

119 def test_unauthorized_access(db, client):

120 endpoints = [

121 ("POST", "/api/v1/networks"),

122 ("GET", "/api/v1/networks"),

123 ("DELETE", "/api/v1/networks/test"),

124 ("POST", "/api/v1/templates"),

125 ("DELETE", "/api/v1/templates/test"),

126 ("DELETE", "/api/v1/hosts/test"),

127 ]

128

129 for method, endpoint in endpoints:

130 if method == "POST":

131 response = client.post(

132 endpoint,

133 data="{}",

134 content_type="application/json",

135 )

136 else:

137 response = (

138 client.get(endpoint) if method == "GET" else client.delete(endpoint)

139 )

140 assert response.status_code == 401, f"{method} {endpoint} should require auth"

141

142

143 def test_wrong_client_id(db, client, keycloak_key, settings):

144 jwt_token = JWT(

145 header={"alg": "RS256", "kid": keycloak_key["kid"]},

146 claims={

147 "exp": 9999999999,

148 "iat": 1741328648,

149 "iss": settings.KEYCLOAK_ISSUER,

150 "azp": "wrong-client", # Not admin-cli

151 "typ": "Bearer",

152 },

153 )

154 jwt_token.make_signed_token(keycloak_key["private_key"])

155 headers = {"HTTP_AUTHORIZATION": f"Bearer {jwt_token.serialize()}"}

156 response = client.get("/api/v1/networks", **headers)

157 assert response.status_code == 401

158

159

160 def test_wrong_signature(db, client, keycloak_key, mocker):

161 different_key = JWK.generate(kty="RSA", size=2048)

162 jwt_token = JWT(

163 header={"alg": "RS256", "kid": keycloak_key["kid"]},

164 claims={

165 "exp": 9999999999,

166 "iat": 1741328648,

167 "iss": "http://localhost:8080/realms/meshadmin",

168 "azp": "admin-cli",

169 "typ": "Bearer",

170 },

171 )

172 jwt_token.make_signed_token(different_key)

173 mock_response = mocker.Mock()

174 mock_response.json.return_value = {"keys": [keycloak_key["public_key"]]}

175 mock_response.raise_for_status.return_value = None

176 mocker.patch("requests.get", return_value=mock_response)

177 headers = {"HTTP_AUTHORIZATION": f"Bearer {jwt_token.serialize()}"}

178 response = client.get("/api/v1/networks", **headers)

179 assert response.status_code == 401

180

181

182 def test_get_config(db, client, test_network):

183 network = test_network(name="test_network", cidr="10.0.0.0/24")

184 auth_key = JWK.generate(kty="RSA", size=2048)

185 public_auth_key = auth_key.export_public()

186 _, public_key = create_keys()

187

188 host = Host.objects.create(

189 network=network,

190 name="test-host",

191 assigned_ip="10.0.0.1",

192 public_key=public_key,

193 public_auth_key=public_auth_key,

194 public_auth_kid=auth_key.thumbprint(),

195 )

196 jwt_token = JWT(

197 header={"alg": "RS256", "kid": auth_key.thumbprint()},

198 claims={

199 "exp": 9999999999,

200 "kid": auth_key.thumbprint(),

201 },

202 )

203 jwt_token.make_signed_token(auth_key)

204 host_auth_headers = {"HTTP_AUTHORIZATION": f"Bearer {jwt_token.serialize()}"}

205 response = client.get(

206 "/api/v1/config",

207 **host_auth_headers,

208 )

209

210 assert response.status_code == 200

211 assert response["Content-Type"] == "text/yaml"

212 host.refresh_from_db()

213 assert host.last_config_refresh is not None

214 assert host.last_config_refresh > timezone.now() - timedelta(minutes=1)

215

216

217 def test_cleanup_ephemeral_hosts(db, client, test_network):

218 network = test_network(name="test_network", cidr="10.0.0.0/24")

219 auth_key = JWK.generate(kty="RSA", size=2048)

220 public_auth_key = auth_key.export_public()

221 _, public_key = create_keys()

222

223 Host.objects.create(

224 network=network,

225 name="requesting-host",

226 assigned_ip="10.0.0.10",

227 public_key=public_key,

228 public_auth_key=public_auth_key,

229 public_auth_kid=auth_key.thumbprint(),

230 )

231 jwt_token = JWT(

232 header={"alg": "RS256", "kid": auth_key.thumbprint()},

233 claims={

234 "exp": 9999999999,

235 "kid": auth_key.thumbprint(),

236 },

237 )

238 jwt_token.make_signed_token(auth_key)

239 host_auth_headers = {"HTTP_AUTHORIZATION": f"Bearer {jwt_token.serialize()}"}

240

241 # Create some ephemeral hosts

242 # 1. A stale host (last_config_refresh > 10 minutes ago)

243 stale_host = Host.objects.create(

244 network=network,

245 name="stale-host",

246 assigned_ip="10.0.0.1",

247 public_key="test-key-1",

248 public_auth_key="{}",

249 public_auth_kid="test-kid-1",

250 is_ephemeral=True,

251 last_config_refresh=timezone.now() - timedelta(minutes=15),

252 )

253

254 # 2. A recently seen ephemeral host (should not be removed)

255 recent_host = Host.objects.create(

256 network=network,

257 name="recent-host",

258 assigned_ip="10.0.0.2",

259 public_key="test-key-2",

260 public_auth_key="{}",

261 public_auth_kid="test-kid-2",

262 is_ephemeral=True,

263 last_config_refresh=timezone.now() - timedelta(minutes=5),

264 )

265

266 # 3. A non-ephemeral host (should not be removed regardless of last_config_refresh)

267 non_ephemeral_host = Host.objects.create(

268 network=network,

269 name="non-ephemeral-host",

270 assigned_ip="10.0.0.3",

271 public_key="test-key-3",

272 public_auth_key="{}",

273 public_auth_kid="test-kid-3",

274 is_ephemeral=False,

275 last_config_refresh=timezone.now() - timedelta(minutes=30),

276 )

277

278 response = client.post(

279 "/api/v1/cleanup-ephemeral",

280 **host_auth_headers,

281 )

282

283 assert response.status_code == 200

284 result = response.json()

285 assert result["removed_count"] == 1

286 assert not Host.objects.filter(id=stale_host.id).exists()

287 assert Host.objects.filter(id=recent_host.id).exists()

288 assert Host.objects.filter(id=non_ephemeral_host.id).exists()

289

290 # Call the endpoint again - should remove no hosts

291 response = client.post(

292 "/api/v1/cleanup-ephemeral",

293 **host_auth_headers,

294 )

295 assert response.status_code == 200

296 result = response.json()

297 assert result["removed_count"] == 0

298

299

300 def test_enrollment_api_with_jwt(test_network, client):

301 network = test_network(name="testnet", cidr="10.0.0.0/24")

302 template = create_template(

303 "jwt_template",

304 network.name,

305 )

306 token = generate_enrollment_token(template)

307 auth_key = JWK.generate(kty="RSA", size=2048)

308 _, public_net_key = create_keys()

309 enrollment_data = schemas.ClientEnrollment(

310 enrollment_key=token,

311 public_net_key=public_net_key,

312 public_auth_key=auth_key.export_public(),

313 preferred_hostname="test-host",

314 public_ip="127.0.0.1",

315 enroll_on_existence=False,

316 )

317 response = client.post(

318 "/api/v1/enroll",

319 data=enrollment_data.model_dump_json(),

320 content_type="application/json",

321 )

322 assert response.status_code == 200

323 assert Host.objects.filter(name="test-host").exists()

324

325

326 def test_download_nebula_binary(client, mocker):

327 mock_content = b"mock binary content"

328 mock_file = mocker.mock_open(read_data=mock_content)

329 mocker.patch("builtins.open", mock_file)

330 mocker.patch("meshadmin.server.assets.asset_path")

331 mocker.patch.object(Path, "exists", return_value=True)

332

333 # Test valid download for Linux (x86_64)

334 response = client.get("/api/v1/nebula/download/Linux/x86_64/nebula")

335 assert response.status_code == 200

336 assert response["Content-Type"] == "application/octet-stream"

337 assert response["Content-Disposition"] == 'attachment; filename="nebula"'

338 content = b"".join(response.streaming_content)

339 assert content == mock_content

340

341 # Test valid download for Linux (aarch64)

342 response = client.get("/api/v1/nebula/download/Linux/aarch64/nebula")

343 assert response.status_code == 200

344

345 # Test valid download for Darwin (arm64)

346 response = client.get("/api/v1/nebula/download/Darwin/arm64/nebula-cert")

347 assert response.status_code == 200

348

349 # Test invalid OS

350 response = client.get("/api/v1/nebula/download/Windows/x86_64/nebula")

351 assert response.status_code == 400

352 assert "Only Linux and Darwin are supported" in str(response.content)

353

354 # Test invalid architecture for Darwin

355 response = client.get("/api/v1/nebula/download/Darwin/x86_64/nebula")

356 assert response.status_code == 400

357 assert "Supported architectures for Darwin: ['arm64']" in str(response.content)

358

359 # Test invalid architecture for Linux

360 response = client.get("/api/v1/nebula/download/Linux/arm64/nebula")

361 assert response.status_code == 400

362 assert "Supported architectures for Linux: ['aarch64', 'x86_64']" in str(

363 response.content

364 )

365

366 # Test invalid binary name

367 response = client.get("/api/v1/nebula/download/Linux/x86_64/invalid")

368 assert response.status_code == 400

369

370 # Test binary not found

371 mocker.patch.object(Path, "exists", return_value=False)

372 response = client.get("/api/v1/nebula/download/Linux/x86_64/nebula")

373 assert response.status_code == 404

374 assert "Binary not found" in str(response.content)