Coverage for src/meshadmin/cli/main.py: 32%

534 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-04-10 16:08 +0200

1import asyncio 

2import json 

3import os 

4import platform 

5import shutil 

6import signal 

7import subprocess 

8import sys 

9from datetime import datetime, timedelta 

10from importlib.metadata import PackageNotFoundError, version 

11from pathlib import Path 

12from time import sleep 

13from typing import Annotated 

14from uuid import uuid4 

15 

16import httpx 

17import jwt 

18import structlog 

19import typer 

20import yaml 

21from jwcrypto.jwk import JWK 

22from jwcrypto.jwt import JWT 

23from jwt import decode 

24from rich import print, print_json 

25 

26from meshadmin.cli.config import load_config 

27from meshadmin.common import schemas 

28from meshadmin.common.utils import ( 

29 create_expiration_date, 

30 create_keys, 

31 download_nebula_binaries, 

32 get_default_config_path, 

33 get_nebula_path, 

34 get_public_ip, 

35) 

36 

37app = typer.Typer() 

38logger = structlog.get_logger(__name__) 

39 

40nebula_app = typer.Typer() 

41app.add_typer(nebula_app, name="nebula", help="Manage the nebula service") 

42 

43service_app = typer.Typer() 

44app.add_typer(service_app, name="service", help="Manage the meshadmin service") 

45 

46network_app = typer.Typer() 

47app.add_typer(network_app, name="network", help="Manage networks") 

48 

49template_app = typer.Typer() 

50app.add_typer(template_app, name="template", help="Manage templates") 

51 

52host_app = typer.Typer() 

53app.add_typer(host_app, name="host", help="Manage hosts") 

54 

55host_config_app = typer.Typer() 

56host_app.add_typer(host_config_app, name="config", help="Manage host configurations") 

57 

58context_app = typer.Typer() 

59app.add_typer(context_app, name="context", help="Manage network contexts") 

60 

61 

62def version_callback(value: bool): 

63 if value: 

64 try: 

65 installed_version = version("meshadmin") 

66 typer.echo(f"meshadmin version {installed_version}") 

67 except PackageNotFoundError: 

68 typer.echo("meshadmin is not installed") 

69 raise typer.Exit() 

70 

71 

72def get_context_config(): 

73 if not config.contexts_file.exists(): 

74 print("No contexts found") 

75 raise typer.Exit(1) 

76 

77 with open(config.contexts_file) as f: 

78 contexts = yaml.safe_load(f) or {} 

79 

80 current = os.getenv("MESH_CONTEXT") 

81 if not current: 

82 active_contexts = [ 

83 name for name, data in contexts.items() if data.get("active") 

84 ] 

85 current = active_contexts[0] if active_contexts else None 

86 

87 if not current or current not in contexts: 

88 print("No active context. Please select a context with 'meshadmin context use'") 

89 raise typer.Exit(1) 

90 

91 context_data = contexts[current] 

92 network_dir = config.networks_dir / current 

93 

94 return { 

95 "name": current, 

96 "endpoint": context_data["endpoint"], 

97 "interface": context_data["interface"], 

98 "network_dir": network_dir, 

99 } 

100 

101 

102@app.callback() 

103def main( 

104 ctx: typer.Context, 

105 version: bool = typer.Option( 

106 None, 

107 "--version", 

108 callback=version_callback, 

109 is_eager=True, 

110 help="Show the version and exit.", 

111 ), 

112 config_path: Annotated[ 

113 Path, 

114 typer.Option( 

115 "--config-path", 

116 "-c", 

117 envvar="MESHADMIN_CONFIG_PATH", 

118 help="Path to the configuration directory", 

119 ), 

120 ] = get_default_config_path(), 

121 context: Annotated[ 

122 str, 

123 typer.Option( 

124 "--context", 

125 envvar="MESH_CONTEXT", 

126 help="Name of the context to use", 

127 ), 

128 ] = None, 

129): 

130 global config 

131 config = load_config(config_path) 

132 

133 if context: 

134 if not config.contexts_file.exists(): 

135 print("No contexts found") 

136 raise typer.Exit(1) 

137 

138 with open(config.contexts_file) as f: 

139 contexts = yaml.safe_load(f) or {} 

140 

141 if context not in contexts: 

142 print(f"Context '{context}' not found") 

143 raise typer.Exit(1) 

144 

145 for ctx_name in contexts: 

146 contexts[ctx_name]["active"] = ctx_name == context 

147 

148 with open(config.contexts_file, "w") as f: 

149 yaml.dump(contexts, f) 

150 

151 

152@nebula_app.command() 

153def download(): 

154 try: 

155 context = get_context_config() 

156 nebula_path = get_nebula_path() 

157 if not nebula_path or not Path(nebula_path).exists(): 

158 logger.info("Nebula binaries not found, downloading...") 

159 download_nebula_binaries(context["endpoint"]) 

160 else: 

161 logger.info("Nebula binaries already downloaded") 

162 except Exception as e: 

163 logger.error("Failed to download nebula binaries", error=str(e)) 

164 raise typer.Exit(code=1) 

165 

166 

167@host_app.command(name="enroll") 

168def host_enroll( 

169 enrollment_key: Annotated[ 

170 str, 

171 typer.Argument(envvar="MESH_ENROLLMENT_KEY"), 

172 ], 

173 preferred_hostname: Annotated[ 

174 str, 

175 typer.Option(envvar="MESH_HOSTNAME"), 

176 ] = None, 

177 public_ip: Annotated[ 

178 str, 

179 typer.Option(envvar="MESH_PUBLIC_IP"), 

180 ] = None, 

181): 

182 context = get_context_config() 

183 network_dir = context["network_dir"] 

184 

185 download() 

186 logger.info("enrolling") 

187 

188 network_dir.mkdir(parents=True, exist_ok=True) 

189 

190 # Use shared auth key for all contexts 

191 private_auth_key_path = config.contexts_file.parent / config.private_key 

192 if not private_auth_key_path.exists(): 

193 logger.info("creating auth key") 

194 create_auth_key(private_auth_key_path.parent) 

195 

196 jwk = JWK.from_json(private_auth_key_path.read_text()) 

197 public_auth_key = jwk.export_public() 

198 logger.info("public key for registration", public_key=public_auth_key) 

199 

200 private_net_key_path = network_dir / config.private_net_key_file 

201 public_net_key_path = network_dir / config.public_net_key_file 

202 

203 if public_ip is None: 

204 public_ip = get_public_ip() 

205 logger.info( 

206 "public ip not set, using ip reported by https://checkip.amazonaws.com/", 

207 public_ip=public_ip, 

208 ) 

209 

210 if preferred_hostname is None: 

211 preferred_hostname = platform.node() 

212 logger.info( 

213 "preferred hostname not set, using system hostname", 

214 hostname=preferred_hostname, 

215 ) 

216 

217 if private_net_key_path.exists() and public_net_key_path.exists(): 

218 public_nebula_key = public_net_key_path.read_text() 

219 logger.info( 

220 "private and public nebula key already exists", 

221 public_key=public_nebula_key, 

222 ) 

223 else: 

224 logger.info("creating private and public nebula key") 

225 private, public_nebula_key = create_keys() 

226 private_net_key_path.write_text(private) 

227 private_auth_key_path.chmod(0o600) 

228 public_net_key_path.write_text(public_nebula_key) 

229 public_net_key_path.chmod(0o600) 

230 logger.info( 

231 "private and public nebula key created", public_nebula_key=public_nebula_key 

232 ) 

233 

234 enrollment = schemas.ClientEnrollment( 

235 enrollment_key=enrollment_key, 

236 public_net_key=public_nebula_key, 

237 public_auth_key=public_auth_key, 

238 preferred_hostname=preferred_hostname, 

239 public_ip=public_ip, 

240 interface=context["interface"], 

241 ) 

242 

243 res = httpx.post( 

244 f"{context['endpoint']}/api/v1/enroll", 

245 content=enrollment.model_dump_json(), 

246 headers={"Content-Type": "application/json"}, 

247 ) 

248 res.raise_for_status() 

249 

250 get_config() 

251 logger.info("enrollment response", enrollment=res.content) 

252 logger.info("enrollment finished") 

253 

254 

255@service_app.command(name="install") 

256def service_install(): 

257 context = get_context_config() 

258 network_dir = context["network_dir"] 

259 context_name = context["name"] 

260 os_name = platform.system() 

261 meshadmin_path = shutil.which("meshadmin") 

262 

263 if not meshadmin_path: 

264 logger.error("meshadmin executable not found in PATH") 

265 exit(1) 

266 

267 (network_dir / "env").write_text(f"MESH_CONTEXT={context_name}\n") 

268 

269 if os_name == "Darwin": 

270 plist_content = f"""<?xml version="1.0" encoding="UTF-8"?> 

271<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 

272<plist version="1.0"> 

273<dict> 

274 <key>Label</key> 

275 <string>com.meshadmin.{context_name}</string> 

276 <key>ProgramArguments</key> 

277 <array> 

278 <string>{meshadmin_path}</string> 

279 <string>nebula</string> 

280 <string>start</string> 

281 </array> 

282 <key>EnvironmentVariables</key> 

283 <dict> 

284 <key>MESH_CONTEXT</key> 

285 <string>{context_name}</string> 

286 </dict> 

287 <key>RunAtLoad</key> 

288 <true/> 

289 <key>KeepAlive</key> 

290 <true/> 

291 <key>StandardErrorPath</key> 

292 <string>{network_dir}/error.log</string> 

293 <key>StandardOutPath</key> 

294 <string>{network_dir}/output.log</string> 

295</dict> 

296</plist> 

297""" 

298 launch_agents_dir = Path(os.path.expanduser("~/Library/LaunchAgents")) 

299 if not launch_agents_dir.exists(): 

300 launch_agents_dir.mkdir(exist_ok=True, parents=True) 

301 plist_path = launch_agents_dir / f"com.meshadmin.{context_name}.plist" 

302 plist_path.write_text(plist_content) 

303 subprocess.run(["launchctl", "load", str(plist_path)]) 

304 logger.info( 

305 "meshadmin service installed and started", 

306 plist_path=str(plist_path), 

307 context_name=context_name, 

308 ) 

309 print(f"meshadmin service installed at {plist_path}") 

310 print(f"Context: {context_name}") 

311 print("Service has been loaded and will start automatically on login") 

312 

313 else: 

314 systemd_unit = f"""[Unit] 

315Description=Meshadmin {context_name} 

316Wants=basic.target network-online.target nss-lookup.target time-sync.target 

317After=basic.target network.target network-online.target 

318Before=sshd.service 

319 

320[Service] 

321#Type=notify 

322#NotifyAccess=main 

323SyslogIdentifier={context_name} 

324EnvironmentFile={network_dir}/env 

325ExecReload=/bin/kill -HUP $MAINPID 

326ExecStart={meshadmin_path} nebula start 

327Restart=always 

328 

329[Install] 

330WantedBy=multi-user.target 

331""" 

332 systemd_service_path = Path( 

333 f"/usr/lib/systemd/system/meshadmin-{context_name}.service" 

334 ) 

335 systemd_service_path.write_text(systemd_unit) 

336 subprocess.run(["systemctl", "daemon-reload"]) 

337 subprocess.run(["systemctl", "enable", f"meshadmin-{context_name}"]) 

338 print(f"meshadmin service installed at {systemd_service_path}") 

339 print(f"Context: {context_name}") 

340 print("Service has been enabled and will start automatically on boot") 

341 

342 

343@service_app.command(name="uninstall") 

344def service_uninstall(): 

345 context = get_context_config() 

346 context_name = context["name"] 

347 network_dir = context["network_dir"] 

348 os_name = platform.system() 

349 

350 if os_name == "Darwin": 

351 plist_path = Path( 

352 os.path.expanduser( 

353 f"~/Library/LaunchAgents/com.meshadmin.{context_name}.plist" 

354 ) 

355 ) 

356 if plist_path.exists(): 

357 subprocess.run(["launchctl", "unload", str(plist_path)]) 

358 plist_path.unlink() 

359 env_path = network_dir / "env" 

360 if env_path.exists(): 

361 env_path.unlink() 

362 logger.info("meshadmin service uninstalled", plist_path=str(plist_path)) 

363 print(f"meshadmin service uninstalled from {plist_path}") 

364 else: 

365 logger.warning("meshadmin service not found", plist_path=str(plist_path)) 

366 print("meshadmin service not found, nothing to uninstall") 

367 else: 

368 systemd_service_path = Path( 

369 f"/usr/lib/systemd/system/meshadmin-{context_name}.service" 

370 ) 

371 if systemd_service_path.exists(): 

372 subprocess.run(["systemctl", "stop", f"meshadmin-{context_name}"]) 

373 subprocess.run(["systemctl", "disable", f"meshadmin-{context_name}"]) 

374 subprocess.run(["systemctl", "daemon-reload"]) 

375 systemd_service_path.unlink() 

376 env_path = network_dir / "env" 

377 if env_path.exists(): 

378 env_path.unlink() 

379 logger.info("meshadmin service uninstalled") 

380 print("meshadmin service uninstalled") 

381 else: 

382 logger.warning("meshadmin service not found") 

383 print("meshadmin service not found, nothing to uninstall") 

384 

385 

386@service_app.command(name="start") 

387def service_start(): 

388 context = get_context_config() 

389 context_name = context["name"] 

390 os_name = platform.system() 

391 

392 if os_name == "Darwin": 

393 plist_path = Path( 

394 os.path.expanduser( 

395 f"~/Library/LaunchAgents/com.meshadmin.{context_name}.plist" 

396 ) 

397 ) 

398 if plist_path.exists(): 

399 subprocess.run(["launchctl", "load", str(plist_path)]) 

400 logger.info("meshadmin service started", context=context_name) 

401 print(f"meshadmin service started for context {context_name}") 

402 else: 

403 logger.error("meshadmin service not installed", plist_path=str(plist_path)) 

404 print( 

405 f"meshadmin service not installed for context {context_name}. Run 'meshadmin service install' first." 

406 ) 

407 else: 

408 subprocess.run(["systemctl", "start", f"meshadmin-{context_name}"]) 

409 print(f"meshadmin service started for context {context_name}") 

410 

411 

412@service_app.command(name="stop") 

413def service_stop(): 

414 context = get_context_config() 

415 context_name = context["name"] 

416 os_name = platform.system() 

417 

418 if os_name == "Darwin": 

419 plist_path = Path( 

420 os.path.expanduser( 

421 f"~/Library/LaunchAgents/com.meshadmin.{context_name}.plist" 

422 ) 

423 ) 

424 if plist_path.exists(): 

425 subprocess.run(["launchctl", "unload", str(plist_path)]) 

426 logger.info("meshadmin service stopped", context=context_name) 

427 print(f"meshadmin service stopped for context {context_name}") 

428 else: 

429 logger.error("meshadmin service not installed", plist_path=str(plist_path)) 

430 print( 

431 f"meshadmin service not installed for context {context_name}. Nothing to stop." 

432 ) 

433 else: 

434 subprocess.run(["systemctl", "stop", f"meshadmin-{context_name}"]) 

435 print(f"meshadmin service stopped for context {context_name}") 

436 

437 

438@service_app.command(name="logs") 

439def service_logs( 

440 follow: Annotated[ 

441 bool, 

442 typer.Option("--follow", "-f", help="Follow the logs in real time"), 

443 ] = False, 

444 lines: Annotated[ 

445 int, 

446 typer.Option("--lines", "-n", help="Number of lines to show"), 

447 ] = 50, 

448): 

449 context = get_context_config() 

450 context_name = context["name"] 

451 network_dir = context["network_dir"] 

452 os_name = platform.system() 

453 

454 if os_name == "Darwin": 

455 error_log = network_dir / "error.log" 

456 output_log = network_dir / "output.log" 

457 

458 if not error_log.exists() and not output_log.exists(): 

459 print( 

460 f"No log files found for context {context_name}. Has the service been started?" 

461 ) 

462 raise typer.Exit(1) 

463 

464 if follow: 

465 try: 

466 process = subprocess.Popen( 

467 ["tail", "-f", str(error_log), str(output_log)], 

468 stdout=sys.stdout, 

469 stderr=sys.stderr, 

470 ) 

471 process.wait() 

472 except KeyboardInterrupt: 

473 process.terminate() 

474 else: 

475 for log_file in [output_log, error_log]: 

476 if log_file.exists(): 

477 print(f"\n=== {log_file.name} ===") 

478 result = subprocess.run( 

479 ["tail", f"-n{lines}", str(log_file)], 

480 capture_output=True, 

481 text=True, 

482 ) 

483 print(result.stdout) 

484 else: 

485 try: 

486 cmd = ["journalctl", "-u", f"meshadmin-{context_name}"] 

487 if follow: 

488 cmd.append("-f") 

489 if lines: 

490 cmd.append(f"-n{lines}") 

491 

492 if follow: 

493 process = subprocess.Popen( 

494 cmd, 

495 stdout=sys.stdout, 

496 stderr=sys.stderr, 

497 ) 

498 process.wait() 

499 else: 

500 result = subprocess.run(cmd, capture_output=True, text=True) 

501 print(result.stdout) 

502 

503 except subprocess.CalledProcessError as e: 

504 print(f"Error accessing logs: {e}") 

505 print( 

506 "Make sure the service is installed and you have appropriate permissions." 

507 ) 

508 raise typer.Exit(1) 

509 

510 

511@host_app.command() 

512def create_auth_key( 

513 mesh_config_path: Annotated[ 

514 Path, 

515 typer.Argument(envvar="MESH_CONFIG_PATH"), 

516 ] = get_default_config_path(), 

517): 

518 jwk = JWK.generate(kty="RSA", kid=str(uuid4()), size=2048) 

519 auth_key = mesh_config_path / config.private_key 

520 auth_key.write_text(jwk.export_private()) 

521 auth_key.chmod(0o600) 

522 

523 

524@host_app.command() 

525def show_auth_public_key( 

526 mesh_config_path: Annotated[ 

527 Path, 

528 typer.Argument(envvar="MESH_CONFIG_PATH"), 

529 ] = get_default_config_path(), 

530): 

531 jwk = JWK.from_json((mesh_config_path / config.private_key).read_text()) 

532 print(jwk.export_public()) 

533 

534 

535@host_config_app.command() 

536def get_config(): 

537 private_net_key, public_net_key = create_keys() 

538 context = get_context_config() 

539 private_auth_key = JWK.from_json( 

540 (config.contexts_file.parent / config.private_key).read_text() 

541 ) 

542 

543 loop = asyncio.get_event_loop() 

544 

545 result, _ = loop.run_until_complete( 

546 get_config_from_mesh(context["endpoint"], private_auth_key) 

547 ) 

548 (context["network_dir"] / config.config_path).write_text(result) 

549 

550 

551async def get_config_from_mesh(mesh_admin_endpoint, private_auth_key): 

552 jwt = JWT( 

553 header={"alg": "RS256", "kid": private_auth_key.thumbprint()}, 

554 claims={ 

555 "exp": create_expiration_date(10), 

556 "kid": private_auth_key.thumbprint(), 

557 }, 

558 ) 

559 jwt.make_signed_token(private_auth_key) 

560 token = jwt.serialize() 

561 

562 async with httpx.AsyncClient() as client: 

563 res = await client.get( 

564 f"{mesh_admin_endpoint}/api/v1/config", 

565 headers={"Authorization": f"Bearer {token}"}, 

566 ) 

567 res.raise_for_status() 

568 config = res.text 

569 update_interval = int(res.headers.get("X-Update-Interval", "5")) 

570 return config, update_interval 

571 

572 

573async def cleanup_ephemeral_hosts(mesh_admin_endpoint, private_auth_key): 

574 jwt_token = JWT( 

575 header={"alg": "RS256", "kid": private_auth_key.thumbprint()}, 

576 claims={ 

577 "exp": create_expiration_date(10), 

578 "kid": private_auth_key.thumbprint(), 

579 }, 

580 ) 

581 jwt_token.make_signed_token(private_auth_key) 

582 token = jwt_token.serialize() 

583 

584 async with httpx.AsyncClient() as client: 

585 res = await client.post( 

586 f"{mesh_admin_endpoint}/api/v1/cleanup-ephemeral", 

587 headers={"Authorization": f"Bearer {token}"}, 

588 ) 

589 res.raise_for_status() 

590 return res.json() 

591 

592 

593async def start_nebula(network_dir: Path, mesh_admin_endpoint: str): 

594 await logger.ainfo("starting nebula") 

595 conf_path = network_dir / config.config_path 

596 assert conf_path.exists(), f"Config at {conf_path} does not exist" 

597 

598 private_auth_key_path = config.contexts_file.parent / config.private_key 

599 assert private_auth_key_path.exists(), ( 

600 f"private_key at {private_auth_key_path} does not exist" 

601 ) 

602 

603 async def start_process(): 

604 return await asyncio.create_subprocess_exec( 

605 get_nebula_path(), 

606 "-config", 

607 str(conf_path), 

608 cwd=network_dir, 

609 ) 

610 

611 proc = await start_process() 

612 

613 # Default update interval in seconds 

614 update_interval = 5 

615 

616 while True: 

617 await asyncio.sleep(update_interval) 

618 try: 

619 private_auth_key_path = config.contexts_file.parent / config.private_key 

620 private_auth_key = JWK.from_json(private_auth_key_path.read_text()) 

621 

622 # Check for config updates 

623 try: 

624 new_config, new_update_interval = await get_config_from_mesh( 

625 mesh_admin_endpoint, private_auth_key 

626 ) 

627 

628 if update_interval != new_update_interval: 

629 await logger.ainfo( 

630 "update interval changed", 

631 old_interval=update_interval, 

632 new_interval=new_update_interval, 

633 ) 

634 update_interval = new_update_interval 

635 

636 old_config = conf_path.read_text() 

637 if new_config != old_config: 

638 await logger.ainfo("config changed, reloading") 

639 conf_path.write_text(new_config) 

640 conf_path.chmod(0o600) 

641 

642 try: 

643 proc.send_signal(signal.SIGHUP) 

644 except ProcessLookupError: 

645 await logger.ainfo("process died, restarting") 

646 proc = await start_process() 

647 else: 

648 await logger.ainfo("config not changed") 

649 except httpx.HTTPStatusError as e: 

650 if e.response.status_code == 401: 

651 await logger.aerror( 

652 "Could not get config because of authentication error. Host may have been deleted.", 

653 error=str(e), 

654 response_text=e.response.text, 

655 ) 

656 print( 

657 "Error: Could not get config because of authentication error. Host may have been deleted." 

658 ) 

659 print(f"Server message: {e.response.text}") 

660 break 

661 else: 

662 await logger.aerror("error getting config", error=str(e)) 

663 

664 # Cleanup ephemeral hosts 

665 try: 

666 result = await cleanup_ephemeral_hosts( 

667 mesh_admin_endpoint, private_auth_key 

668 ) 

669 if result.get("removed_count", 0) > 0: 

670 await logger.ainfo( 

671 "removed stale ephemeral hosts", 

672 count=result["removed_count"], 

673 ) 

674 except httpx.HTTPStatusError as e: 

675 if e.response.status_code == 401: 

676 await logger.aerror( 

677 "Could not clean up ephemeral hosts because of authentication error. Host may have been deleted.", 

678 error=str(e), 

679 response_text=e.response.text, 

680 ) 

681 print( 

682 "Error: Could not clean up ephemeral hosts because of authentication error. Host may have been deleted." 

683 ) 

684 print(f"Server message: {e.response.text}") 

685 break 

686 else: 

687 await logger.aerror("error during cleanup operation", error=str(e)) 

688 

689 except Exception: 

690 await logger.aexception("could not refresh token") 

691 if proc.returncode is not None: 

692 await logger.ainfo("process died, restarting") 

693 proc = await start_process() 

694 

695 # Clean shutdown if we get here 

696 if proc.returncode is None: 

697 await logger.ainfo("shutting down nebula process") 

698 proc.terminate() 

699 try: 

700 await asyncio.wait_for(proc.wait(), timeout=5.0) 

701 except asyncio.TimeoutError: 

702 await logger.awarning("nebula process didn't terminate, killing it") 

703 proc.kill() 

704 

705 

706@nebula_app.command() 

707def start(): 

708 context = get_context_config() 

709 asyncio.run(start_nebula(context["network_dir"], context["endpoint"])) 

710 

711 

712@app.command() 

713def login(): 

714 res = httpx.post( 

715 config.keycloak_device_auth_url, 

716 data={ 

717 "client_id": config.keycloak_admin_client, 

718 }, 

719 ) 

720 res.raise_for_status() 

721 

722 device_auth_response = res.json() 

723 print(device_auth_response) 

724 print( 

725 "Please open the verification url", 

726 device_auth_response["verification_uri_complete"], 

727 ) 

728 

729 while True: 

730 res = httpx.post( 

731 config.keycloak_token_url, 

732 data={ 

733 "grant_type": "urn:ietf:params:oauth:grant-type:device_code", 

734 "client_id": config.keycloak_admin_client, 

735 "device_code": device_auth_response["device_code"], 

736 }, 

737 ) 

738 if res.status_code == 200: 

739 logger.info("Received auth token") 

740 config.authentication_path.write_bytes(res.content) 

741 config.authentication_path.chmod(0o600) 

742 

743 access_token = res.json()["access_token"] 

744 refresh_token = res.json()["refresh_token"] 

745 print( 

746 jwt.decode( 

747 refresh_token, 

748 algorithms=["RS256"], 

749 options={"verify_signature": False}, 

750 ) 

751 ) 

752 logger.info("access_token", access_token=access_token) 

753 print("successfully authenticated") 

754 break 

755 else: 

756 print(res.json()) 

757 sleep(device_auth_response["interval"]) 

758 

759 

760def get_access_token(): 

761 if config.authentication_path.exists(): 

762 auth = json.loads(config.authentication_path.read_text()) 

763 access_token = auth["access_token"] 

764 

765 decoded_token = decode( 

766 access_token, options={"verify_signature": False, "verify_exp": False} 

767 ) 

768 

769 # is exp still 2/3 of the time 

770 if decoded_token["exp"] >= (datetime.now() + timedelta(seconds=10)).timestamp(): 

771 return access_token 

772 else: 

773 refresh_token = auth["refresh_token"] 

774 res = httpx.post( 

775 config.keycloak_token_url, 

776 data={ 

777 "grant_type": "refresh_token", 

778 "refresh_token": refresh_token, 

779 "client_id": config.keycloak_admin_client, 

780 }, 

781 ) 

782 res.raise_for_status() 

783 config.authentication_path.write_bytes(res.content) 

784 return res.json()["access_token"] 

785 

786 else: 

787 print("authentication failed") 

788 

789 

790@network_app.command(name="create") 

791def create_network(name: str, cidr: str): 

792 try: 

793 access_token = get_access_token() 

794 except Exception: 

795 logger.exception("failed to get access token") 

796 exit(1) 

797 

798 context = get_context_config() 

799 res = httpx.post( 

800 f"{context['endpoint']}/api/v1/networks", 

801 content=schemas.NetworkCreate(name=name, cidr=cidr).model_dump_json(), 

802 headers={"Authorization": f"Bearer {access_token}"}, 

803 ) 

804 

805 if res.status_code >= 400: 

806 print("could not create network:", res.text) 

807 exit(1) 

808 

809 print_json(res.content.decode("utf-8")) 

810 

811 

812@network_app.command(name="list") 

813def list_networks(): 

814 try: 

815 access_token = get_access_token() 

816 except Exception: 

817 logger.exception("failed to get access token") 

818 exit(1) 

819 

820 context = get_context_config() 

821 res = httpx.get( 

822 f"{context['endpoint']}/api/v1/networks", 

823 headers={"Authorization": f"Bearer {access_token}"}, 

824 ) 

825 res.raise_for_status() 

826 print(res.json()) 

827 

828 

829@template_app.command(name="create") 

830def create_template( 

831 name: str, network_name: str, is_lighthouse: bool, is_relay: bool, use_relay: bool 

832): 

833 try: 

834 access_token = get_access_token() 

835 except Exception: 

836 logger.exception("failed to get access token") 

837 exit(1) 

838 

839 context = get_context_config() 

840 res = httpx.post( 

841 f"{context['endpoint']}/api/v1/templates", 

842 content=schemas.TemplateCreate( 

843 name=name, 

844 network_name=network_name, 

845 is_lighthouse=is_lighthouse, 

846 is_relay=is_relay, 

847 use_relay=use_relay, 

848 ).model_dump_json(), 

849 headers={"Authorization": f"Bearer {access_token}"}, 

850 ) 

851 res.raise_for_status() 

852 print_json(res.content.decode("utf-8")) 

853 

854 

855@template_app.command() 

856def get_token(name: str): 

857 try: 

858 access_token = get_access_token() 

859 except Exception: 

860 logger.exception("failed to get access token") 

861 exit(1) 

862 

863 context = get_context_config() 

864 res = httpx.get( 

865 f"{context['endpoint']}/api/v1/templates/{name}/token", 

866 headers={"Authorization": f"Bearer {access_token}"}, 

867 ) 

868 res.raise_for_status() 

869 print(res.text) 

870 

871 

872@template_app.command(name="delete") 

873def delete_template(name: str): 

874 try: 

875 access_token = get_access_token() 

876 except Exception: 

877 logger.exception("failed to get access token") 

878 exit(1) 

879 

880 context = get_context_config() 

881 res = httpx.delete( 

882 f"{context['endpoint']}/api/v1/templates/{name}", 

883 headers={"Authorization": f"Bearer {access_token}"}, 

884 ) 

885 res.raise_for_status() 

886 print(res.json()) 

887 

888 

889@host_app.command(name="delete") 

890def delete_host(name: str): 

891 try: 

892 access_token = get_access_token() 

893 except Exception: 

894 logger.exception("failed to get access token") 

895 exit(1) 

896 

897 context = get_context_config() 

898 res = httpx.delete( 

899 f"{context['endpoint']}/api/v1/hosts/{name}", 

900 headers={"Authorization": f"Bearer {access_token}"}, 

901 ) 

902 res.raise_for_status() 

903 print(res.json()) 

904 

905 

906@context_app.command(name="create") 

907def create_context( 

908 name: Annotated[str, typer.Argument()], 

909 endpoint: Annotated[str, typer.Option()], 

910 interface: Annotated[str, typer.Option()] = "nebula1", 

911): 

912 config.contexts_file.parent.mkdir(parents=True, exist_ok=True) 

913 

914 contexts = {} 

915 if config.contexts_file.exists(): 

916 with open(config.contexts_file) as f: 

917 contexts = yaml.safe_load(f) or {} 

918 

919 # If this is the first context, make it active 

920 is_first = len(contexts) == 0 

921 

922 contexts[name] = { 

923 "endpoint": endpoint, 

924 "interface": interface, 

925 "active": is_first, 

926 } 

927 

928 with open(config.contexts_file, "w") as f: 

929 yaml.dump(contexts, f) 

930 

931 print(f"Created context '{name}'") 

932 if is_first: 

933 print(f"Set '{name}' as active context") 

934 

935 

936@context_app.command(name="use") 

937def use_context(name: str): 

938 if not config.contexts_file.exists(): 

939 print("No contexts found") 

940 raise typer.Exit(1) 

941 

942 with open(config.contexts_file) as f: 

943 contexts = yaml.safe_load(f) or {} 

944 

945 if name not in contexts: 

946 print(f"Context '{name}' not found") 

947 raise typer.Exit(1) 

948 

949 # Deactivate all contexts and activate the selected one 

950 for context_name in contexts: 

951 contexts[context_name]["active"] = False 

952 contexts[name]["active"] = True 

953 

954 with open(config.contexts_file, "w") as f: 

955 yaml.dump(contexts, f) 

956 

957 print(f"Switched to context '{name}'") 

958 

959 

960@context_app.command(name="list") 

961def list_contexts(): 

962 if not config.contexts_file.exists(): 

963 print("No contexts found") 

964 return 

965 

966 with open(config.contexts_file) as f: 

967 contexts = yaml.safe_load(f) 

968 

969 for name, data in contexts.items(): 

970 print( 

971 f"{'* ' if data.get('active') else ' '}{name} ({data['endpoint']}) ({data['interface']})" 

972 ) 

973 

974 

975@host_config_app.command(name="info") 

976def show_config_info(): 

977 print("\nConfiguration Paths:") 

978 print(f"Contexts file: {config.contexts_file}") 

979 print(f"Networks directory: {config.networks_dir}") 

980 print(f"Authentication file: {config.authentication_path}") 

981 try: 

982 context = get_context_config() 

983 print("\nCurrent Context:") 

984 print(f"Name: {context['name']}") 

985 print(f"Endpoint: {context['endpoint']}") 

986 print(f"Interface: {context['interface']}") 

987 print(f"Network directory: {context['network_dir']}") 

988 

989 config_file = context["network_dir"] / config.config_path 

990 env_file = context["network_dir"] / "env" 

991 private_key = context["network_dir"] / config.private_net_key_file 

992 

993 print("\nContext Files:") 

994 print( 

995 f"Config file: {config_file} {'(exists)' if config_file.exists() else '(not found)'}" 

996 ) 

997 print( 

998 f"Environment file: {env_file} {'(exists)' if env_file.exists() else '(not found)'}" 

999 ) 

1000 print( 

1001 f"Private key: {private_key} {'(exists)' if private_key.exists() else '(not found)'}" 

1002 ) 

1003 if platform.system() == "Darwin": 

1004 service_file = Path( 

1005 os.path.expanduser( 

1006 f"~/Library/LaunchAgents/com.meshadmin.{context['name']}.plist" 

1007 ) 

1008 ) 

1009 print( 

1010 f"Service file: {service_file} {'(exists)' if service_file.exists() else '(not found)'}" 

1011 ) 

1012 else: 

1013 service_file = Path( 

1014 f"/usr/lib/systemd/system/meshadmin-{context['name']}.service" 

1015 ) 

1016 print( 

1017 f"Service file: {service_file} {'(exists)' if service_file.exists() else '(not found)'}" 

1018 ) 

1019 except typer.Exit: 

1020 print("\nNo active context found") 

1021 

1022 

1023if __name__ == "__main__": 

1024 app()