import asyncio import os import shutil import time from pathlib import Path from typing import Dict, Any, List, Optional, Tuple from backend.config import load_settings, DEFAULT_KEY_FILE, DEFAULT_PUB_KEY_FILE def get_public_key() -> str: if DEFAULT_PUB_KEY_FILE.exists(): return DEFAULT_PUB_KEY_FILE.read_text(encoding="utf-8").strip() return "" def ensure_ssh_key() -> str: if not DEFAULT_KEY_FILE.exists(): DEFAULT_KEY_FILE.parent.mkdir(parents=True, exist_ok=True) os.system(f'ssh-keygen -t ed25519 -f "{DEFAULT_KEY_FILE}" -N "" -C "pangolin-sentinel@unraid" -q') if DEFAULT_KEY_FILE.exists(): try: os.chmod(DEFAULT_KEY_FILE, 0o600) except Exception: pass return get_public_key() def resolve_key_path(configured_path: Optional[str] = None) -> str: if configured_path and os.path.exists(configured_path): return configured_path if DEFAULT_KEY_FILE.exists(): return str(DEFAULT_KEY_FILE) return configured_path or str(DEFAULT_KEY_FILE) def build_ssh_prefix(settings: Optional[Dict[str, Any]] = None) -> List[str]: if not settings: settings = load_settings() host = settings.get("vps_host", "").strip() port = str(settings.get("vps_port", 22)) user = settings.get("vps_user", "root").strip() auth_method = settings.get("vps_auth_method", "key") raw_key_path = settings.get("vps_ssh_key_path", str(DEFAULT_KEY_FILE)) key_path = resolve_key_path(raw_key_path) password = settings.get("vps_ssh_password", "") ssh_opts = [ "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=8", "-o", "ServerAliveInterval=15", "-p", port ] if auth_method == "password" and password: return ["sshpass", "-p", password, "ssh"] + ssh_opts + [f"{user}@{host}"] else: # Use Key Auth if os.path.exists(key_path): try: os.chmod(key_path, 0o600) except Exception: pass return ["ssh", "-i", key_path, "-o", "BatchMode=yes"] + ssh_opts + [f"{user}@{host}"] return ["ssh", "-o", "BatchMode=yes"] + ssh_opts + [f"{user}@{host}"] def build_scp_prefix(settings: Optional[Dict[str, Any]] = None) -> List[str]: if not settings: settings = load_settings() port = str(settings.get("vps_port", 22)) auth_method = settings.get("vps_auth_method", "key") raw_key_path = settings.get("vps_ssh_key_path", str(DEFAULT_KEY_FILE)) key_path = resolve_key_path(raw_key_path) password = settings.get("vps_ssh_password", "") scp_opts = [ "-P", port, "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10" ] if auth_method == "password" and password: return ["sshpass", "-p", password, "scp"] + scp_opts else: if os.path.exists(key_path): try: os.chmod(key_path, 0o600) except Exception: pass return ["scp", "-i", key_path, "-o", "BatchMode=yes"] + scp_opts return ["scp", "-o", "BatchMode=yes"] + scp_opts async def run_remote_quick(cmd: str, settings: Optional[Dict[str, Any]] = None) -> Tuple[int, str, str]: prefix = build_ssh_prefix(settings) full_cmd = prefix + [cmd] try: proc = await asyncio.create_subprocess_exec( *full_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await proc.communicate() return proc.returncode, stdout.decode('utf-8', errors='replace'), stderr.decode('utf-8', errors='replace') except Exception as e: return -1, "", str(e) async def test_vps_connection(custom_settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: s = custom_settings or load_settings() host = s.get("vps_host", "").strip() if not host: return { "success": False, "connected": False, "error": "No VPS Host / IP configured. Please specify the VPS address in Settings." } # Run comprehensive diagnostic script on remote VPS diag_script = """ echo "===PING_OK===" uname -n cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2 | tr -d '"' uname -r uptime -p 2>/dev/null || uptime which docker >/dev/null 2>&1 && echo "DOCKER_FOUND" || echo "DOCKER_NOT_FOUND" docker ps >/dev/null 2>&1 && echo "DOCKER_RUNNING" || echo "DOCKER_STOPPED" """ ret, stdout, stderr = await run_remote_quick(diag_script, s) if ret != 0 or "===PING_OK===" not in stdout: err = stderr.strip() or "Connection failed. Please check Host, Port, and SSH credentials." if "Permission denied" in err: err = "Permission denied (SSH authentication failed). Please ensure your SSH public key or password is correct on the VPS." elif "Connection timed out" in err or "No route to host" in err: err = f"Could not reach {host}:{s.get('vps_port', 22)}. Verify firewall, IP address, and SSH port." return { "success": False, "connected": False, "error": err } lines = [line.strip() for line in stdout.splitlines() if line.strip()] hostname = lines[1] if len(lines) > 1 else host os_name = lines[2] if len(lines) > 2 else "Linux" kernel = lines[3] if len(lines) > 3 else "" uptime_str = lines[4] if len(lines) > 4 else "" docker_found = "DOCKER_FOUND" in stdout docker_running = "DOCKER_RUNNING" in stdout # Check Pangolin path on VPS p_dir = s.get("vps_pangolin_dir", "/opt/pangolin") p_script = f""" [ -d "{p_dir}" ] && echo "DIR_OK" || echo "DIR_MISSING" [ -f "{p_dir}/docker-compose.yml" ] || [ -f "{p_dir}/compose.yaml" ] && echo "COMPOSE_OK" || echo "COMPOSE_MISSING" find "{p_dir}" -name "*.db" -o -name "*.sqlite" 2>/dev/null | head -n 1 """ _, p_out, _ = await run_remote_quick(p_script, s) p_lines = [l.strip() for l in p_out.splitlines() if l.strip()] dir_ok = "DIR_OK" in p_lines compose_ok = "COMPOSE_OK" in p_lines db_file = None for l in p_lines: if l.endswith((".db", ".sqlite")): db_file = l break return { "success": True, "connected": True, "hostname": hostname, "os": os_name, "kernel": kernel, "uptime": uptime_str, "docker_installed": docker_found, "docker_running": docker_running, "pangolin_dir_exists": dir_ok, "compose_file_exists": compose_ok, "database_file": db_file, "message": f"Successfully connected to {hostname} ({host}) via SSH!" }