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
« 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
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
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)
37app = typer.Typer()
38logger = structlog.get_logger(__name__)
40nebula_app = typer.Typer()
41app.add_typer(nebula_app, name="nebula", help="Manage the nebula service")
43service_app = typer.Typer()
44app.add_typer(service_app, name="service", help="Manage the meshadmin service")
46network_app = typer.Typer()
47app.add_typer(network_app, name="network", help="Manage networks")
49template_app = typer.Typer()
50app.add_typer(template_app, name="template", help="Manage templates")
52host_app = typer.Typer()
53app.add_typer(host_app, name="host", help="Manage hosts")
55host_config_app = typer.Typer()
56host_app.add_typer(host_config_app, name="config", help="Manage host configurations")
58context_app = typer.Typer()
59app.add_typer(context_app, name="context", help="Manage network contexts")
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()
72def get_context_config():
73 if not config.contexts_file.exists():
74 print("No contexts found")
75 raise typer.Exit(1)
77 with open(config.contexts_file) as f:
78 contexts = yaml.safe_load(f) or {}
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
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)
91 context_data = contexts[current]
92 network_dir = config.networks_dir / current
94 return {
95 "name": current,
96 "endpoint": context_data["endpoint"],
97 "interface": context_data["interface"],
98 "network_dir": network_dir,
99 }
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)
133 if context:
134 if not config.contexts_file.exists():
135 print("No contexts found")
136 raise typer.Exit(1)
138 with open(config.contexts_file) as f:
139 contexts = yaml.safe_load(f) or {}
141 if context not in contexts:
142 print(f"Context '{context}' not found")
143 raise typer.Exit(1)
145 for ctx_name in contexts:
146 contexts[ctx_name]["active"] = ctx_name == context
148 with open(config.contexts_file, "w") as f:
149 yaml.dump(contexts, f)
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)
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"]
185 download()
186 logger.info("enrolling")
188 network_dir.mkdir(parents=True, exist_ok=True)
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)
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)
200 private_net_key_path = network_dir / config.private_net_key_file
201 public_net_key_path = network_dir / config.public_net_key_file
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 )
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 )
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 )
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 )
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()
250 get_config()
251 logger.info("enrollment response", enrollment=res.content)
252 logger.info("enrollment finished")
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")
263 if not meshadmin_path:
264 logger.error("meshadmin executable not found in PATH")
265 exit(1)
267 (network_dir / "env").write_text(f"MESH_CONTEXT={context_name}\n")
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")
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
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
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")
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()
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")
386@service_app.command(name="start")
387def service_start():
388 context = get_context_config()
389 context_name = context["name"]
390 os_name = platform.system()
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}")
412@service_app.command(name="stop")
413def service_stop():
414 context = get_context_config()
415 context_name = context["name"]
416 os_name = platform.system()
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}")
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()
454 if os_name == "Darwin":
455 error_log = network_dir / "error.log"
456 output_log = network_dir / "output.log"
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)
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}")
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)
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)
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)
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())
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 )
543 loop = asyncio.get_event_loop()
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)
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()
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
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()
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()
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"
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 )
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 )
611 proc = await start_process()
613 # Default update interval in seconds
614 update_interval = 5
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())
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 )
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
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)
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))
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))
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()
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()
706@nebula_app.command()
707def start():
708 context = get_context_config()
709 asyncio.run(start_nebula(context["network_dir"], context["endpoint"]))
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()
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 )
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)
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"])
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"]
765 decoded_token = decode(
766 access_token, options={"verify_signature": False, "verify_exp": False}
767 )
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"]
786 else:
787 print("authentication failed")
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)
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 )
805 if res.status_code >= 400:
806 print("could not create network:", res.text)
807 exit(1)
809 print_json(res.content.decode("utf-8"))
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)
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())
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)
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"))
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)
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)
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)
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())
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)
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())
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)
914 contexts = {}
915 if config.contexts_file.exists():
916 with open(config.contexts_file) as f:
917 contexts = yaml.safe_load(f) or {}
919 # If this is the first context, make it active
920 is_first = len(contexts) == 0
922 contexts[name] = {
923 "endpoint": endpoint,
924 "interface": interface,
925 "active": is_first,
926 }
928 with open(config.contexts_file, "w") as f:
929 yaml.dump(contexts, f)
931 print(f"Created context '{name}'")
932 if is_first:
933 print(f"Set '{name}' as active context")
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)
942 with open(config.contexts_file) as f:
943 contexts = yaml.safe_load(f) or {}
945 if name not in contexts:
946 print(f"Context '{name}' not found")
947 raise typer.Exit(1)
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
954 with open(config.contexts_file, "w") as f:
955 yaml.dump(contexts, f)
957 print(f"Switched to context '{name}'")
960@context_app.command(name="list")
961def list_contexts():
962 if not config.contexts_file.exists():
963 print("No contexts found")
964 return
966 with open(config.contexts_file) as f:
967 contexts = yaml.safe_load(f)
969 for name, data in contexts.items():
970 print(
971 f"{'* ' if data.get('active') else ' '}{name} ({data['endpoint']}) ({data['interface']})"
972 )
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']}")
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
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")
1023if __name__ == "__main__":
1024 app()