Compare commits

..
8 Commits
5 changed files with 199 additions and 94 deletions

No files matched your search

+80 -10
View File
@@ -87,17 +87,87 @@ async def run_backup_task(
remote_commands = [
f'echo "=== STEP 1: Creating Hot-Snapshot on Remote VPS ({vps_host}) ==="',
f'mkdir -p "{remote_stage}/pangolin"',
f'echo "--- Staging Pangolin files from {p_dir} ---"',
f'if [ -d "{p_dir}" ]; then rsync -av --exclude="*.db" --exclude="*.db-wal" --exclude="*.db-shm" "{p_dir}/" "{remote_stage}/pangolin/"; fi',
f'echo "--- Performing zero-lock hot-backup of SQLite databases on VPS ---"',
f"""
for db in $(find "{p_dir}" -name "*.db" -o -name "*.sqlite" 2>/dev/null); do
rel=$(realpath --relative-to="{p_dir}" "$db")
mkdir -p "{remote_stage}/pangolin/$(dirname "$rel")"
echo "Hot-backing up $rel..."
sqlite3 "$db" ".backup '{remote_stage}/pangolin/$rel'" 2>/dev/null || cp -v "$db" "{remote_stage}/pangolin/$rel"
f'''
eval P_DIR="{p_dir}"
P_DIR="${{P_DIR/#~/$HOME}}"
# Auto-discovery if configured directory does not exist or is empty
if [ ! -d "$P_DIR" ] || [ -z "$(ls -A "$P_DIR" 2>/dev/null)" ]; then
echo "Notice: Configured path $P_DIR is empty or does not exist."
echo "Searching VPS for Pangolin Docker compose stack..."
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 "/opt/pangolin" "$HOME/pangolin" "/root/pangolin" "$HOME/config" "/root/config" "$HOME" "/root"; do
if [ -d "$candidate" ] && [ -n "$(ls -A "$candidate" 2>/dev/null)" ]; then
DETECTED="$candidate"
break
fi
done
fi
if [ -n "$DETECTED" ] && [ -d "$DETECTED" ]; then
echo "--> Auto-discovered active Pangolin directory at: $DETECTED"
P_DIR="$DETECTED"
fi
fi
# Standard HHF Pangolin stack items
BACKUP_ITEMS=("docker-compose.yml" "compose.yaml" "docker-compose.yaml" ".env" "config" "crowdsec" "traefik")
echo "--- Staging Pangolin files from $P_DIR ---"
if [ -d "$P_DIR" ]; then
if [ -f "$P_DIR/docker-compose.yml" ] || [ -d "$P_DIR/config" ]; then
echo "Detected Pangolin stack root at $P_DIR"
for item in "${{BACKUP_ITEMS[@]}}"; do
if [ -e "$P_DIR/$item" ]; then
echo "Staging $item..."
mkdir -p "{remote_stage}/pangolin/$(dirname "$item")"
cp -a "$P_DIR/$item" "{remote_stage}/pangolin/$item" 2>/dev/null || true
fi
done
else
echo "Staging contents of $P_DIR into {remote_stage}/pangolin/config/..."
mkdir -p "{remote_stage}/pangolin/config"
rsync -av --exclude="*.db" --exclude="*.db-wal" --exclude="*.db-shm" "$P_DIR/" "{remote_stage}/pangolin/config/"
# Check parent folder for companion compose and env files
PARENT_DIR="$(dirname "$P_DIR")"
for extra in docker-compose.yml compose.yaml docker-compose.yaml .env; do
if [ -f "$PARENT_DIR/$extra" ]; then
echo "--- Staging companion $extra from $PARENT_DIR ---"
cp "$PARENT_DIR/$extra" "{remote_stage}/pangolin/" 2>/dev/null || true
fi
done
fi
fi
echo "--- Performing zero-lock hot-backup of SQLite databases on VPS ---"
if [ -d "$P_DIR/config" ]; then
TARGET_BASE="{remote_stage}/pangolin/config"
SEARCH_BASE="$P_DIR/config"
else
TARGET_BASE="{remote_stage}/pangolin"
SEARCH_BASE="$P_DIR"
fi
for db in $(find "$SEARCH_BASE" -maxdepth 4 -name "*.db" -o -name "*.sqlite" 2>/dev/null); do
rel=$(realpath --relative-to="$SEARCH_BASE" "$db")
mkdir -p "$TARGET_BASE/$(dirname "$rel")"
echo "Hot-backing up SQLite database: $rel..."
sqlite3 "$db" ".backup '$TARGET_BASE/$rel'" 2>/dev/null || cp -v "$db" "$TARGET_BASE/$rel"
done
"""
# Clean dangling lock/WAL files in staging archive
find "" -name "*.db-wal" -delete -o -name "*.db-shm" -delete 2>/dev/null || true
# Backup any named Docker volumes attached to pangolin containers
for vol in $(docker inspect --format '{{{{range .Mounts}}}}{{{{if eq .Type "volume"}}}}{{{{.Source}}}}{{{{"\\n"}}}}{{{{end}}}}{{{{end}}}}' $(docker ps -aq --filter "name=pangolin" 2>/dev/null) 2>/dev/null); do
if [ -d "$vol" ] && [ -n "$(ls -A "$vol" 2>/dev/null)" ]; then
vname=$(basename "$vol")
echo "--- Staging Docker Volume: $vname from $vol ---"
mkdir -p "{remote_stage}/pangolin/volumes/$vname"
cp -a "$vol/." "{remote_stage}/pangolin/volumes/$vname/" 2>/dev/null || true
fi
done
'''
]
if scope in ["vps_config", "full"]:
+56 -9
View File
@@ -31,11 +31,29 @@ async def get_pangolin_stack_status() -> Dict[str, Any]:
# Query Remote VPS for files, DB, and Docker status
check_script = f"""
[ -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"
eval P_DIR="{p_dir}"
P_DIR="${{P_DIR/#~/$HOME}}"
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 "$HOME/config" "/root/config" "$HOME/pangolin" "/root/pangolin" "$HOME" "/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)
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}}'
@@ -45,7 +63,7 @@ else
fi
# Docker containers
docker ps -a --format '{{json .}}' 2>/dev/null || true
docker ps -a --format '{{{{json .}}}}' 2>/dev/null || true
"""
ret, stdout, stderr = await run_remote_quick(check_script, settings)
@@ -54,7 +72,7 @@ docker ps -a --format '{{json .}}' 2>/dev/null || true
"vps_configured": True,
"connected": False,
"installed": False,
"path": p_dir,
"path": resolved_path,
"compose_exists": False,
"config_exists": False,
"database": {"found": False, "path": None, "size_bytes": 0},
@@ -66,6 +84,12 @@ docker ps -a --format '{{json .}}' 2>/dev/null || true
}
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)
@@ -107,7 +131,7 @@ docker ps -a --format '{{json .}}' 2>/dev/null || true
"vps_configured": True,
"connected": True,
"installed": compose_exists,
"path": p_dir,
"path": resolved_path,
"compose_exists": compose_exists,
"config_exists": config_exists,
"database": {
@@ -133,16 +157,39 @@ async def trigger_pangolin_update() -> Dict[str, Any]:
# Remote update script executed on the VPS
remote_script = f"""
eval P_DIR="{p_dir}"
P_DIR="${{P_DIR/#~/$HOME}}"
# Locate compose directory
if [ -f "$P_DIR/docker-compose.yml" ] || [ -f "$P_DIR/compose.yaml" ]; then
COMPOSE_DIR="$P_DIR"
elif [ -f "$(dirname "$P_DIR")/docker-compose.yml" ] || [ -f "$(dirname "$P_DIR")/compose.yaml" ]; then
COMPOSE_DIR="$(dirname "$P_DIR")"
else
COMPOSE_DIR="$P_DIR"
fi
echo "=== STEP 1: Connecting to Pangolin VPS ({vps_host}) ==="
cd "{p_dir}" || exit 1
echo "Operating Directory: $COMPOSE_DIR"
cd "$COMPOSE_DIR" || exit 1
# Detect docker compose version
if docker compose version >/dev/null 2>&1; then
DC="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
DC="docker-compose"
else
echo "ERROR: Neither 'docker compose' nor 'docker-compose' found on VPS."
exit 1
fi
echo ""
echo "=== STEP 2: Pulling latest images on VPS ==="
docker compose pull
$DC pull
echo ""
echo "=== STEP 3: Applying stack updates & recreating containers ==="
docker compose up -d --remove-orphans
$DC up -d --remove-orphans
echo ""
echo "=== STEP 4: Verifying running container status ==="
+53 -66
View File
@@ -4,7 +4,7 @@ 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
from backend.config import load_settings, DEFAULT_KEY_FILE, DEFAULT_PUB_KEY_FILE, DATA_DIR
def get_public_key() -> str:
if DEFAULT_PUB_KEY_FILE.exists():
@@ -40,9 +40,12 @@ def build_ssh_prefix(settings: Optional[Dict[str, Any]] = None) -> List[str]:
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", "")
known_hosts = str(DATA_DIR / "known_hosts")
ssh_opts = [
"-T",
"-o", "StrictHostKeyChecking=accept-new",
"-o", f"UserKnownHostsFile={known_hosts}",
"-o", "ConnectTimeout=8",
"-o", "ServerAliveInterval=15",
"-p", port
@@ -69,10 +72,12 @@ def build_scp_prefix(settings: Optional[Dict[str, Any]] = None) -> List[str]:
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", "")
known_hosts = str(DATA_DIR / "known_hosts")
scp_opts = [
"-P", port,
"-o", "StrictHostKeyChecking=accept-new",
"-o", f"UserKnownHostsFile={known_hosts}",
"-o", "ConnectTimeout=10"
]
@@ -99,79 +104,61 @@ async def run_remote_quick(cmd: str, settings: Optional[Dict[str, Any]] = None)
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)
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()
async def test_vps_connection(settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not settings:
settings = load_settings()
host = settings.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."
}
return {"success": False, "error": "No VPS host configured. Please provide IP or hostname."}
# 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"
# 1. Test basic SSH connectivity & gather OS summary
cmd_ping = 'echo "PANGOLIN_PONG" && uname -srm && cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d= -f2 | tr -d \'"\''
code, stdout, stderr = await run_remote_quick(cmd_ping, settings)
if code != 0:
err_msg = stderr.strip() or "Connection timed out or failed."
if "Permission denied" in err_msg:
err_msg += " (SSH Authentication failed. Check key in ~/.ssh/authorized_keys or password)"
return {"success": False, "error": err_msg}
lines = stdout.strip().split("\n")
os_name = lines[2] if len(lines) > 2 else (lines[1] if len(lines) > 1 else "Linux")
kernel = lines[1] if len(lines) > 1 else ""
# 2. Check Docker availability on VPS
code_docker, stdout_docker, _ = await run_remote_quick('docker --version', settings)
docker_installed = (code_docker == 0)
docker_version = stdout_docker.strip() if docker_installed else "Not detected"
# 3. Check Pangolin Directory
pangolin_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip() or "/opt/pangolin"
chk_dir_cmd = f"""
eval P_DIR="{pangolin_dir}"
P_DIR="${{P_DIR/#~/$HOME}}"
[ -d "$P_DIR" ] && echo "DIR_EXISTS" || echo "DIR_MISSING"
"""
ret, stdout, stderr = await run_remote_quick(diag_script, s)
_, stdout_dir, _ = await run_remote_quick(chk_dir_cmd, settings)
pangolin_dir_found = "DIR_EXISTS" in stdout_dir
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
# 4. Check SQLite database status in Pangolin directory
chk_db_cmd = f"""
eval P_DIR="{pangolin_dir}"
P_DIR="${{P_DIR/#~/$HOME}}"
find "$P_DIR" -name "*.db" -o -name "*.sqlite" 2>/dev/null
"""
_, 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
_, stdout_db, _ = await run_remote_quick(chk_db_cmd, settings)
dbs_found = [p.strip() for p in stdout_db.strip().split("\n") if p.strip()]
return {
"success": True,
"connected": True,
"hostname": hostname,
"os": os_name,
"os_name": 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!"
"docker_installed": docker_installed,
"docker_version": docker_version,
"pangolin_dir_found": pangolin_dir_found,
"sqlite_databases": dbs_found,
"message": "SSH Connection successful! Remote host is ready for backups and updates."
}
+4 -3
View File
@@ -5,7 +5,8 @@ set -e
DATA_PATH="${DATA_DIR:-/data}"
BACKUP_PATH="${BACKUP_DIR:-/backups}"
mkdir -p "$DATA_PATH" "$BACKUP_PATH"
mkdir -p "$DATA_PATH" "$BACKUP_PATH" "$DATA_PATH/.ssh"
export HOME="$DATA_PATH"
# Set secure permissions on SSH private key if it exists
if [ -f "$DATA_PATH/id_ed25519" ]; then
@@ -22,9 +23,9 @@ if [ "$PUID" -ne 0 ] && [ "$PGID" -ne 0 ]; then
groupadd -g "$PGID" pangolin 2>/dev/null || true
fi
# Create or update user
# Create or update user with persistent home in DATA_PATH
if ! getent passwd pangolin >/dev/null 2>&1; then
useradd -u "$PUID" -g "$PGID" -d /app -s /bin/bash pangolin 2>/dev/null || true
useradd -u "$PUID" -g "$PGID" -d "$DATA_PATH" -s /bin/bash pangolin 2>/dev/null || true
fi
# Ensure ownership of persistent data and backup volumes
+6 -6
View File
@@ -1,19 +1,19 @@
<?xml version="1.0"?>
<Container version="2">
<Name>PangolinSentinel</Name>
<Repository>pangolin-sentinel:latest</Repository>
<Registry>https://hub.docker.com/</Registry>
<Repository>fluffyranger/pangolin-sentinel:latest</Repository>
<Registry>https://hub.docker.com/r/fluffyranger/pangolin-sentinel</Registry>
<Network>bridge</Network>
<MyIP/>
<Shell>bash</Shell>
<Privileged>false</Privileged>
<Support/>
<Project>https://github.com/fosrl/pangolin</Project>
<Support>https://github.com/FluffyRanger/PangolinSentinel/issues</Support>
<Project>https://github.com/FluffyRanger/PangolinSentinel</Project>
<Overview>Pangolin Sentinel is a dedicated Unraid-to-VPS backup and update manager. Pull zero-lock hot backups of your remote Pangolin Reverse Proxy stack (SQLite databases, Traefik SSL certs, configurations) directly into your Unraid array, trigger remote stack updates, and monitor remote VPS OS packages and metrics over secure SSH.</Overview>
<Category>Backup: Tools: Network:Management</Category>
<WebUI>http://[IP]:[PORT:8888]/</WebUI>
<TemplateURL/>
<Icon>http://[IP]:[PORT:8888]/assets/icon.png</Icon>
<TemplateURL>https://raw.githubusercontent.com/FluffyRanger/PangolinSentinel/main/pangolin-sentinel-unraid.xml</TemplateURL>
<Icon>https://raw.githubusercontent.com/FluffyRanger/PangolinSentinel/main/icon.png</Icon>
<ExtraParams/>
<PostArgs/>
<CPUset/>