Files
PangolinSentinel/backend/pangolin.py
T

263 lines
9.1 KiB
Python

import asyncio
import json
import os
import time
from pathlib import Path
from typing import Dict, Any, List, Optional
import yaml
from backend.runner import runner
from backend.config import load_settings, get_setting
from backend.remote import run_remote_quick, build_ssh_prefix
async def get_pangolin_stack_status() -> Dict[str, Any]:
settings = load_settings()
vps_host = settings.get("vps_host", "").strip()
p_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip()
if not vps_host:
return {
"vps_configured": False,
"installed": False,
"path": p_dir,
"compose_exists": False,
"config_exists": False,
"database": {"found": False, "path": None, "size_bytes": 0},
"containers": [],
"running_count": 0,
"total_count": 0,
"api_online": False,
"message": "Configure VPS Host and SSH credentials in Settings to manage Pangolin."
}
# Query Remote VPS for files, DB, and Docker status
check_script = f"""
P_DIR="{p_dir}"
if [ ! -d "$P_DIR" ] || [ -z "$(ls -A "$P_DIR" 2>/dev/null)" ]; then
DETECTED=$(docker inspect --format '{{{{index .Config.Labels \"com.docker.compose.project.working_dir\"}}}}' $(docker ps -aq --filter "name=pangolin" 2>/dev/null) 2>/dev/null | head -n 1)
if [ -z "$DETECTED" ] || [ ! -d "$DETECTED" ]; then
for candidate in /root/pangolin /opt/pangolin /home/*/pangolin /var/lib/pangolin /srv/pangolin /etc/pangolin; do
if [ -d "$candidate" ] && [ -n "$(ls -A "$candidate" 2>/dev/null)" ]; then
DETECTED="$candidate"
break
fi
done
fi
if [ -n "$DETECTED" ] && [ -d "$DETECTED" ]; then
P_DIR="$DETECTED"
fi
fi
echo "RESOLVED_DIR:$P_DIR"
[ -f "$P_DIR/docker-compose.yml" ] || [ -f "$P_DIR/compose.yaml" ] && echo "COMPOSE_EXISTS" || echo "COMPOSE_NO"
[ -f "$P_DIR/config/config.yml" ] || [ -f "$P_DIR/config.yml" ] && echo "CONFIG_EXISTS" || echo "CONFIG_NO"
# Check DB
db=$(find "$P_DIR" -name "*.db" -o -name "*.sqlite" 2>/dev/null | head -n 1)
if [ -n "$db" ]; then
echo "DB_PATH:$db"
wc -c "$db" 2>/dev/null | awk '{{print $1}}'
else
echo "DB_PATH:NONE"
echo "0"
fi
# Docker containers
docker ps -a --format '{{{{json .}}}}' 2>/dev/null || true
"""
ret, stdout, stderr = await run_remote_quick(check_script, settings)
if ret != 0:
return {
"vps_configured": True,
"connected": False,
"installed": False,
"path": resolved_path,
"compose_exists": False,
"config_exists": False,
"database": {"found": False, "path": None, "size_bytes": 0},
"containers": [],
"running_count": 0,
"total_count": 0,
"api_online": False,
"error": stderr.strip() or "SSH connection to VPS failed."
}
lines = stdout.splitlines()
resolved_path = p_dir
for l in lines:
if l.startswith("RESOLVED_DIR:"):
resolved_path = l.split(":", 1)[1].strip()
break
compose_exists = any("COMPOSE_EXISTS" in l for l in lines)
config_exists = any("CONFIG_EXISTS" in l for l in lines)
db_path = None
db_size = 0
for idx, l in enumerate(lines):
if l.startswith("DB_PATH:") and not l.endswith(":NONE"):
db_path = l.split(":", 1)[1].strip()
if idx + 1 < len(lines) and lines[idx + 1].strip().isdigit():
db_size = int(lines[idx + 1].strip())
break
# Parse Docker JSON lines
containers = []
for l in lines:
l = l.strip()
if not l or not l.startswith("{"):
continue
try:
data = json.loads(l)
names = data.get("Names", "")
image = data.get("Image", "")
if any(k in names.lower() or k in image.lower() for k in ["pangolin", "gerbil", "traefik", "newt"]):
containers.append({
"id": data.get("ID", ""),
"name": names,
"image": image,
"status": data.get("Status", ""),
"state": data.get("State", ""),
"created": data.get("CreatedAt", ""),
"ports": data.get("Ports", "")
})
except Exception:
continue
running = sum(1 for c in containers if c.get("state") == "running")
return {
"vps_configured": True,
"connected": True,
"installed": compose_exists,
"path": resolved_path,
"compose_exists": compose_exists,
"config_exists": config_exists,
"database": {
"found": db_path is not None,
"path": db_path,
"size_bytes": db_size
},
"containers": containers,
"running_count": running,
"total_count": len(containers),
"api_online": running > 0
}
async def trigger_pangolin_update() -> Dict[str, Any]:
settings = load_settings()
vps_host = settings.get("vps_host", "").strip()
p_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip()
if not vps_host:
return {"success": False, "error": "No VPS configured. Set VPS Host in Settings."}
ssh_prefix = build_ssh_prefix(settings)
# Remote update script executed on the VPS
remote_script = f"""
echo "=== STEP 1: Connecting to Pangolin VPS ({vps_host}) ==="
cd "{p_dir}" || exit 1
echo ""
echo "=== STEP 2: Pulling latest images on VPS ==="
docker compose pull
echo ""
echo "=== STEP 3: Applying stack updates & recreating containers ==="
docker compose up -d --remove-orphans
echo ""
echo "=== STEP 4: Verifying running container status ==="
docker compose ps
echo ""
echo "=== STEP 5: Cleaning up unused images on VPS ==="
docker image prune -f
echo ""
echo "=== Remote Pangolin Stack update completed successfully! ==="
"""
full_cmd = ssh_prefix + [remote_script]
res = await runner.run_command_job(
name=f"Update Remote Pangolin Stack ({vps_host})",
cmd=full_cmd,
job_type="update_pangolin",
description=f"Pull latest docker images and recreate Pangolin stack on remote VPS ({vps_host})"
)
return res
async def container_action(container_name: str, action: str) -> Dict[str, Any]:
if action not in ["start", "stop", "restart"]:
return {"success": False, "error": "Invalid action"}
settings = load_settings()
vps_host = settings.get("vps_host", "").strip()
if not vps_host:
return {"success": False, "error": "No VPS configured."}
ret, stdout, stderr = await run_remote_quick(f"docker {action} {container_name}", settings)
if ret == 0:
return {"success": True, "output": stdout.strip()}
return {"success": False, "error": stderr.strip()}
async def get_container_logs(container_name: str, lines: int = 150) -> Dict[str, Any]:
settings = load_settings()
vps_host = settings.get("vps_host", "").strip()
if not vps_host:
return {"success": False, "error": "No VPS configured."}
ret, stdout, stderr = await run_remote_quick(f"docker logs --tail {lines} {container_name} 2>&1", settings)
return {"success": True, "logs": stdout or stderr or "No logs available."}
async def get_stack_config_files() -> Dict[str, Any]:
settings = load_settings()
vps_host = settings.get("vps_host", "").strip()
p_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip()
if not vps_host:
return {}
candidates = ["docker-compose.yml", "compose.yaml", ".env", "config/config.yml", "config.yml"]
result = {}
for c in candidates:
ret, stdout, _ = await run_remote_quick(f'[ -f "{p_dir}/{c}" ] && cat "{p_dir}/{c}"', settings)
if ret == 0 and stdout:
result[c] = stdout
return result
async def save_stack_config_file(filename: str, content: str) -> Dict[str, Any]:
settings = load_settings()
vps_host = settings.get("vps_host", "").strip()
p_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip()
if not vps_host:
return {"success": False, "error": "No VPS configured."}
if ".." in filename or filename.startswith("/"):
return {"success": False, "error": "Invalid filename path."}
# Validate YAML locally before pushing to VPS
if filename.endswith((".yml", ".yaml")):
try:
yaml.safe_load(content)
except yaml.YAMLError as ye:
return {"success": False, "error": f"Invalid YAML syntax: {str(ye)}"}
# Push to remote VPS with backup of old file
safe_target = f"{p_dir}/{filename}"
remote_write_script = f"""
mkdir -p "$(dirname '{safe_target}')"
if [ -f '{safe_target}' ]; then
cp '{safe_target}' '{safe_target}.bak.{int(time.time())}'
fi
cat << 'PANGOLIN_EOF_SAVE' > '{safe_target}'
{content}
PANGOLIN_EOF_SAVE
"""
ret, _, stderr = await run_remote_quick(remote_write_script, settings)
if ret == 0:
return {"success": True, "message": f"Saved {filename} on remote VPS with automatic snapshot backup."}
return {"success": False, "error": f"Failed to save on remote VPS: {stderr}"}