Initial commit: Pangolin Sentinel Docker & Unraid Hub

This commit is contained in:
jschantli committed 2026-09-04 19:46:04 -07:00
1 parent 178b40ba37
commit b4095b82ea
24 files changed
+4355

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
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!"
}