From 91918e7cfc18b71913a4c53406520fcb42532662 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Tue, 24 Mar 2026 14:02:51 +0000 Subject: [PATCH] feat: add CLI command endpoint and standalone CLI client for pyMC Repeater --- pyproject.toml | 1 + repeater/handler_helpers/repeater_cli.py | 107 ++++++++++++++++- repeater/local_cli.py | 146 +++++++++++++++++++++++ repeater/web/api_endpoints.py | 41 +++++++ 4 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 repeater/local_cli.py diff --git a/pyproject.toml b/pyproject.toml index 771c230..7bdf927 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ dev = [ [project.scripts] pymc-repeater = "repeater.main:main" +pymc-cli = "repeater.local_cli:main" [tool.setuptools.packages.find] where = ["."] diff --git a/repeater/handler_helpers/repeater_cli.py b/repeater/handler_helpers/repeater_cli.py index 58e9e3d..c710cfc 100644 --- a/repeater/handler_helpers/repeater_cli.py +++ b/repeater/handler_helpers/repeater_cli.py @@ -88,8 +88,12 @@ class MeshCLI: def _route_command(self, command: str) -> str: """Route command to appropriate handler method.""" + # Help + if command == "help" or command.startswith("help "): + return self._cmd_help(command) + # System commands - if command == "reboot": + elif command == "reboot": return self._cmd_reboot() elif command == "advert": return self._cmd_advert() @@ -156,7 +160,106 @@ class MeshCLI: else: return "Unknown command" - # ==================== System Commands ==================== + # ==================== Help Command ==================== + + def _cmd_help(self, command: str) -> str: + """Show available commands or detailed help for a specific command.""" + parts = command.split(None, 1) + if len(parts) == 2: + return self._help_detail(parts[1]) + + lines = [ + "=== pyMC CLI Commands ===", + "", + "System:", + " reboot Restart the repeater service", + " advert Send self advertisement", + " clock Show current UTC time", + " clock sync Sync clock (no-op, uses system time)", + " ver Show version info", + " password Change admin password", + " clear stats Clear statistics", + "", + "Get:", + " get name Node name", + " get radio Radio params (freq,bw,sf,cr)", + " get freq Frequency (MHz)", + " get tx TX power", + " get af Airtime factor", + " get repeat Repeat mode (on/off)", + " get lat / get lon GPS coordinates", + " get role Identity role", + " get guest.password Guest password", + " get allow.read.only Read-only access setting", + " get advert.interval Advert interval (minutes)", + " get flood.advert.interval Flood advert interval (hours)", + " get flood.max Max flood hops", + " get rxdelay RX delay base", + " get txdelay TX delay factor", + " get direct.txdelay Direct TX delay factor", + " get multi.acks Multi-ack count", + " get int.thresh Interference threshold", + " get agc.reset.interval AGC reset interval", + "", + "Set: (use 'help set' for details)", + " set ", + "", + "Other:", + " neighbors List neighbors", + " neighbor.remove Remove neighbor by pubkey", + " tempradio ", + " setperm Set ACL permissions", + " log start|stop|erase Logging control", + ] + if self.enable_regions: + lines.append(" region ... Region commands") + lines += ["", "Type 'help ' for details on a specific command."] + return "\n".join(lines) + + def _help_detail(self, topic: str) -> str: + """Return detailed help for a specific command topic.""" + topic = topic.strip() + details = { + "set": ( + "Set commands — set :\n" + " set name Set node name\n" + " set radio Set radio (restart required)\n" + " set freq Set frequency (restart required)\n" + " set tx Set TX power\n" + " set af Airtime factor\n" + " set repeat on|off Enable/disable repeating\n" + " set lat Latitude\n" + " set lon Longitude\n" + " set guest.password Guest password\n" + " set allow.read.only on|off Read-only access\n" + " set advert.interval 60-240 minutes\n" + " set flood.advert.interval
3-48 hours\n" + " set flood.max Max flood hops (max 64)\n" + " set rxdelay RX delay base (>=0)\n" + " set txdelay TX delay factor (>=0)\n" + " set direct.txdelay Direct TX delay (>=0)\n" + " set multi.acks Multi-ack count\n" + " set int.thresh Interference threshold\n" + " set agc.reset.interval AGC reset (rounded to x4)" + ), + "get": "Get commands — type 'help' to see all 'get' parameters.", + "reboot": "Restart the repeater service via systemd.", + "advert": "Trigger a self-advertisement flood packet.", + "clock": "'clock' shows UTC time. 'clock sync' is a no-op (system time used).", + "ver": "Show repeater version and identity type.", + "password": "password — Change the admin password.", + "tempradio": ( + "tempradio \n" + " Apply temporary radio parameters that revert after timeout.\n" + " freq: 300-2500 MHz, bw: 7-500 kHz, sf: 5-12, cr: 5-8" + ), + "neighbors": "List known neighbor nodes from the routing table.", + "setperm": "setperm — Set ACL permissions for a node.", + "log": "log start|stop|erase — Control logging.", + } + return details.get(topic, f"No detailed help for '{topic}'. Type 'help' for command list.") + + # ==================== System Commands == def _cmd_reboot(self) -> str: """Reboot the repeater process.""" diff --git a/repeater/local_cli.py b/repeater/local_cli.py new file mode 100644 index 0000000..b376740 --- /dev/null +++ b/repeater/local_cli.py @@ -0,0 +1,146 @@ +""" +CLI client for pyMC Repeater. +Connects to an already-running repeater daemon via its HTTP API. +Reads admin password and HTTP port from the local config.yaml automatically. +""" + +import sys + + +CONFIG_PATHS = [ + "/etc/pymc_repeater/config.yaml", + "config.yaml", +] + + +def _load_config(config_path=None): + """Load repeater config.yaml, trying common paths.""" + import yaml + from pathlib import Path + + paths = [config_path] if config_path else CONFIG_PATHS + for p in paths: + path = Path(p) + if path.is_file(): + with open(path) as f: + return yaml.safe_load(f) or {} + return {} + + +def run_client_cli(host: str = "127.0.0.1", port: int = 8000, password: str = ""): + """ + Standalone CLI client that connects to a running repeater's HTTP API. + """ + import urllib.request + import urllib.error + import json + + base_url = f"http://{host}:{port}" + + # Authenticate to get JWT token + token = None + if password: + try: + auth_data = json.dumps({ + "username": "admin", + "password": password, + "client_id": "pymc-cli", + }).encode() + req = urllib.request.Request( + f"{base_url}/auth/login", + data=auth_data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + result = json.loads(resp.read()) + token = result.get("token") or result.get("data", {}).get("token") + except urllib.error.URLError as e: + print(f"Error: Cannot connect to repeater at {base_url} — {e.reason}") + sys.exit(1) + except Exception as e: + print(f"Authentication failed: {e}") + sys.exit(1) + + if not token: + print("Error: Authentication failed. Check password or repeater status.") + sys.exit(1) + + print(f"\npyMC Repeater CLI (connected to {base_url})") + print("Type 'help' for available commands, 'exit' to quit.\n") + + while True: + try: + command = input(">> ").strip() + except (EOFError, KeyboardInterrupt): + print() + break + + if not command: + continue + if command in ("exit", "quit"): + break + + try: + payload = json.dumps({"command": command}).encode() + req = urllib.request.Request( + f"{base_url}/api/cli", + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10) as resp: + result = json.loads(resp.read()) + if result.get("success"): + print(result["data"]["reply"]) + else: + print(f"Error: {result.get('error', 'Unknown error')}") + except urllib.error.URLError as e: + print(f"Connection error: {e.reason}") + except Exception as e: + print(f"Error: {e}") + + +def main(): + """Entry point for pymc-cli command.""" + import argparse + + parser = argparse.ArgumentParser( + description="Connect to a running pyMC Repeater and issue CLI commands" + ) + parser.add_argument( + "--config", default=None, + help="Path to config.yaml (auto-detected if not set)", + ) + parser.add_argument( + "--host", default=None, + help="Repeater HTTP host (default: 127.0.0.1)", + ) + parser.add_argument( + "--port", type=int, default=None, + help="Repeater HTTP port (default: from config or 8000)", + ) + args = parser.parse_args() + + # Load config to get password and port automatically + config = _load_config(args.config) + repeater_cfg = config.get("repeater", {}) + security_cfg = repeater_cfg.get("security", {}) + password = security_cfg.get("admin_password", "") + + if not password: + print("Error: No admin_password found in config.yaml.") + print("Searched: " + ", ".join(CONFIG_PATHS)) + sys.exit(1) + + host = args.host or "127.0.0.1" + port = args.port or config.get("http", {}).get("port", 8000) + + run_client_cli(host=host, port=port, password=password) + + +if __name__ == "__main__": + main() diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index ff7b5df..9f2c364 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -4307,6 +4307,47 @@ class APIEndpoints: logger.error(f"Error clearing room messages: {e}") return self._error(e) + # ====================== + # CLI Command Endpoint + # ====================== + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + @require_auth + def cli(self): + """Execute a CLI command on the running repeater. + POST /api/cli {"command": "get name"} + Returns {"success": true, "reply": "..."} + """ + self._set_cors_headers() + if cherrypy.request.method == "OPTIONS": + return "" + try: + self._require_post() + data = cherrypy.request.json + command = data.get("command", "").strip() + if not command: + return self._error("Missing 'command' field") + + if not self.daemon_instance or not hasattr(self.daemon_instance, "text_helper"): + return self._error("Repeater not initialized") + text_helper = self.daemon_instance.text_helper + if not text_helper or not hasattr(text_helper, "cli") or not text_helper.cli: + return self._error("CLI handler not available") + + reply = text_helper.cli.handle_command( + sender_pubkey=b"api-cli", + command=command, + is_admin=True, + ) + return self._success({"reply": reply}) + except cherrypy.HTTPError: + raise + except Exception as e: + logger.error(f"CLI endpoint error: {e}", exc_info=True) + return self._error(str(e)) + # ====================== # OpenAPI Documentation # ======================