import shlex from urllib.parse import urlparse import paramiko class SSHControlError(RuntimeError): pass def _derive_host_from_url(rpc_url: str) -> str: try: parsed = urlparse(rpc_url) return parsed.hostname or "" except ValueError: return "" def run_remote_command(settings: dict, command: str) -> dict: ssh_host = (settings.get("ssh_host") or "").strip() or _derive_host_from_url(settings.get("rpc_url", "")) ssh_username = (settings.get("ssh_username") or "").strip() ssh_password = settings.get("ssh_password") or "" ssh_key_path = (settings.get("ssh_key_path") or "").strip() ssh_port = int(settings.get("ssh_port") or 22) if not ssh_host: raise SSHControlError("SSH host is required to run start/restart commands") if not ssh_username: raise SSHControlError("SSH username is required to run start/restart commands") client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) connect_args = { "hostname": ssh_host, "port": ssh_port, "username": ssh_username, "timeout": 10, "look_for_keys": False, "allow_agent": False, } if ssh_key_path: connect_args["key_filename"] = ssh_key_path if ssh_password: connect_args["passphrase"] = ssh_password elif ssh_password: connect_args["password"] = ssh_password else: connect_args["look_for_keys"] = True connect_args["allow_agent"] = True try: client.connect(**connect_args) _, stdout, stderr = client.exec_command(command, timeout=20) exit_code = stdout.channel.recv_exit_status() out = stdout.read().decode("utf-8", errors="replace").strip() err = stderr.read().decode("utf-8", errors="replace").strip() except Exception as exc: # noqa: BLE001 raise SSHControlError(f"SSH command failed: {exc}") from exc finally: client.close() return { "exit_code": exit_code, "stdout": out, "stderr": err, "host": ssh_host, } def build_start_command(settings: dict) -> str: config_path = (settings.get("config_path") or "").strip() bitcoin_binary = (settings.get("bitcoin_binary") or "bitcoind").strip() or "bitcoind" if not config_path: raise SSHControlError("Config file path is required to start bitcoind") return f"{shlex.quote(bitcoin_binary)} -daemon -conf={shlex.quote(config_path)}"