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

+17
View File
@@ -0,0 +1,17 @@
.git
.gitignore
.idea
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
*.iml
data/id_ed25519
data/id_ed25519.pub
data/settings.json
data/backups_index.json
data/jobs_history.json
backups/
*.tar.gz
+25
View File
@@ -0,0 +1,25 @@
# Python & Environment
__pycache__/
*.py[cod]
*$py.class
.venv/
env/
venv/
*.egg-info/
# Sensitive App Data & SSH Keys (Do NOT commit)
data/*
!data/.gitkeep
backups/*
!backups/.gitkeep
# IDE & Editor Files
.idea/
*.iml
.vscode/
# OS & Scratch
.DS_Store
*.swp
*.bak
icon_cropped.png
+62
View File
@@ -0,0 +1,62 @@
FROM python:3.11-slim
# Set environment variables
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
APP_DIR=/app \
DATA_DIR=/data \
BACKUP_DIR=/backups \
PORT=8888 \
HOST=0.0.0.0
# Install required system packages:
# - openssh-client: ssh, scp, ssh-keygen
# - sshpass: password-based SSH authentication
# - rsync: efficient file staging and transfers
# - tar & gzip: archive inspection, creation, and extraction
# - curl & ca-certificates: healthchecks and outbound webhooks
# - procps: psutil and process management
# - sqlite3: SQLite verification
# - gosu: PUID/PGID privilege handling for Unraid / Docker hosts
RUN apt-get update && apt-get install -y --no-install-recommends \
openssh-client \
sshpass \
rsync \
tar \
gzip \
curl \
ca-certificates \
procps \
sqlite3 \
gosu \
bash \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy dependency specifications first for Docker layer caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application source code and scripts
COPY backend/ ./backend/
COPY frontend/ ./frontend/
COPY entrypoint.sh .
# Ensure entrypoint is executable and mount points exist
RUN chmod +x /app/entrypoint.sh && \
mkdir -p /data /backups
# Expose Sentinel Web Dashboard port
EXPOSE 8888
# Persistent volumes for application configuration and backup archives
VOLUME ["/data", "/backups"]
# Health check to monitor dashboard responsiveness
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8888/api/auth/status || exit 1
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8888"]
+88
View File
@@ -0,0 +1,88 @@
import hashlib
import hmac
import os
import secrets
import time
from typing import Optional, Tuple
from fastapi import Request, HTTPException, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from backend.config import get_setting, update_setting, load_settings
security = HTTPBearer(auto_error=False)
def hash_password(password: str, salt: Optional[str] = None) -> Tuple[str, str]:
if not salt:
salt = secrets.token_hex(16)
pwd_bytes = password.encode('utf-8')
salt_bytes = salt.encode('utf-8')
hash_bytes = hashlib.pbkdf2_hmac('sha256', pwd_bytes, salt_bytes, 100000)
return hash_bytes.hex(), salt
def verify_password(password: str, password_hash: str, salt: str) -> bool:
new_hash, _ = hash_password(password, salt)
return hmac.compare_digest(new_hash, password_hash)
def create_session_token(username: str = "admin", expires_in_seconds: int = 86400 * 7) -> str:
secret = get_setting("jwt_secret", "fallback-secret-key-32chars-min-ok")
expiry = int(time.time()) + expires_in_seconds
payload = f"{username}:{expiry}"
signature = hmac.new(secret.encode('utf-8'), payload.encode('utf-8'), hashlib.sha256).hexdigest()
return f"{payload}:{signature}"
def verify_session_token(token: str) -> bool:
try:
secret = get_setting("jwt_secret", "fallback-secret-key-32chars-min-ok")
parts = token.split(":")
if len(parts) != 3:
return False
username, expiry_str, signature = parts
expiry = int(expiry_str)
if time.time() > expiry:
return False
expected_payload = f"{username}:{expiry_str}"
expected_sig = hmac.new(secret.encode('utf-8'), expected_payload.encode('utf-8'), hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected_sig)
except Exception:
return False
def is_setup_required() -> bool:
settings = load_settings()
return not bool(settings.get("admin_password_hash"))
async def require_auth(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
) -> bool:
settings = load_settings()
if not settings.get("auth_enabled", True):
return True
# If setup is required, permit setup endpoints but block others
if is_setup_required():
path = request.url.path
if path in ["/api/auth/status", "/api/auth/setup", "/api/auth/login"]:
return True
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Initial setup required. Please configure admin password."
)
token = None
# 1. Check Bearer Authorization header
if credentials:
token = credentials.credentials
# 2. Check Cookie
if not token and "session_token" in request.cookies:
token = request.cookies.get("session_token")
# 3. Check Query Parameter (for WebSockets / EventSource / direct download links)
if not token and "token" in request.query_params:
token = request.query_params.get("token")
if not token or not verify_session_token(token):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unauthorized. Please log in."
)
return True
+454
View File
@@ -0,0 +1,454 @@
import asyncio
import hashlib
import json
import os
import shutil
import tarfile
import time
from pathlib import Path
from typing import Dict, Any, List, Optional
from backend.config import load_settings, get_setting, BACKUP_INDEX_FILE, DEFAULT_LOCAL_BACKUP, DATA_DIR
from backend.runner import runner
from backend.remote import run_remote_quick, build_ssh_prefix, build_scp_prefix
from backend.system import format_bytes
def get_backup_dir() -> Path:
s = load_settings()
configured = s.get("backup_dir", "").strip()
if configured:
bdir = Path(configured)
else:
bdir = Path(DEFAULT_LOCAL_BACKUP)
try:
bdir.mkdir(parents=True, exist_ok=True)
return bdir
except Exception as e:
print(f"Warning: could not create backup directory {bdir}: {e}")
fallback = Path("/backups") if os.path.exists("/backups") else (DATA_DIR / "backups")
fallback.mkdir(parents=True, exist_ok=True)
return fallback
def load_backup_index() -> List[Dict[str, Any]]:
if not BACKUP_INDEX_FILE.exists():
return []
try:
with open(BACKUP_INDEX_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
def save_backup_index(index: List[Dict[str, Any]]) -> None:
try:
BACKUP_INDEX_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(BACKUP_INDEX_FILE, "w", encoding="utf-8") as f:
json.dump(index, f, indent=2)
except Exception as e:
print(f"Error saving backup index: {e}")
def compute_sha256(filepath: Path) -> str:
h = hashlib.sha256()
with open(filepath, "rb") as f:
while chunk := f.read(65536):
h.update(chunk)
return h.hexdigest()
async def run_backup_task(
scope: str = "pangolin",
description: str = "",
auto_created: bool = False
) -> Dict[str, Any]:
settings = load_settings()
vps_host = settings.get("vps_host", "").strip()
p_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip()
backup_dir = get_backup_dir()
if not vps_host:
return {"success": False, "error": "No VPS host configured. Please configure your VPS in Settings first."}
timestamp = time.strftime("%Y%m%d_%H%M%S")
backup_id = f"bk_{timestamp}_{int(time.time()) % 10000:04d}"
archive_name = f"pangolin-backup_{scope}_{timestamp}.tar.gz"
local_archive_path = backup_dir / archive_name
remote_stage = f"/tmp/pangolin_snap_{backup_id}"
remote_tar = f"/tmp/{archive_name}"
# Build multi-step backup workflow script executed on Unraid server
# 1. SSH to VPS: stage files, hot backup SQLite, tar on VPS
# 2. SCP/rsync from VPS to Unraid server
# 3. Clean up remote VPS /tmp
# 4. Verify local archive on Unraid server
ssh_prefix_str = " ".join(build_ssh_prefix(settings))
scp_prefix_str = " ".join(build_scp_prefix(settings))
vps_user = settings.get("vps_user", "root")
vps_port = str(settings.get("vps_port", 22))
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"
done
"""
]
if scope in ["vps_config", "full"]:
remote_commands.append(f"""
echo "--- Staging VPS Host Configurations (/etc/ufw, network, cron) ---"
mkdir -p "{remote_stage}/vps_config/etc"
for t in /etc/ufw /etc/network /etc/netplan /etc/hosts /etc/hostname /etc/resolv.conf /etc/systemd/system/pangolin*.service /etc/cron* /var/spool/cron; do
[ -e "$t" ] && cp -r --parents "$t" "{remote_stage}/vps_config/" 2>/dev/null || true
done
""")
# Metadata & Tar on VPS
remote_commands.append(f"""
cat << 'MANIFEST_EOF' > "{remote_stage}/backup_manifest.json"
{{
"id": "{backup_id}",
"scope": "{scope}",
"source_vps": "{vps_host}",
"timestamp": "{timestamp}",
"created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"description": "{description}",
"auto_created": {str(auto_created).lower()}
}}
MANIFEST_EOF
echo "--- Compressing snapshot on VPS into {remote_tar} ---"
tar -czf "{remote_tar}" -C "{remote_stage}" .
rm -rf "{remote_stage}"
echo "Remote snapshot prepared successfully."
""")
remote_script_one_liner = "\n".join(remote_commands)
# Full bash execution pipeline on Unraid server
unraid_script = f"""
set -e
echo "================================================================"
echo " Starting Pangolin VPS Backup -> Pull to Unraid Server"
echo " Remote VPS : {vps_user}@{vps_host}:{vps_port}"
echo " Local Store: {local_archive_path}"
echo "================================================================"
# Step 1: Execute remote snapshot creation
{ssh_prefix_str} << 'REMOTE_EXEC_EOF'
{remote_script_one_liner}
REMOTE_EXEC_EOF
echo ""
echo "=== STEP 2: Transferring backup archive to Unraid Server ==="
mkdir -p "{backup_dir}"
{scp_prefix_str} "{vps_user}@{vps_host}:{remote_tar}" "{local_archive_path}"
echo ""
echo "=== STEP 3: Cleaning up temporary staging on Remote VPS ==="
{ssh_prefix_str} "rm -f '{remote_tar}'"
echo ""
echo "=== STEP 4: Verifying archive integrity on Unraid Server ==="
tar -tzf "{local_archive_path}" > /dev/null && echo "Archive integrity check: PASSED"
echo -n "Unraid Local SHA256: "
sha256sum "{local_archive_path}"
echo ""
echo "=== BACKUP PULL COMPLETED SUCCESSFULLY ==="
"""
res = await runner.run_command_job(
name=f"Backup VPS -> Unraid ({scope.upper()})",
cmd=unraid_script,
job_type="backup",
description=description or f"Pangolin VPS backup to Unraid ({scope})"
)
if res.get("success"):
asyncio.create_task(_monitor_and_index_backup(
res["job_id"], backup_id, local_archive_path, scope, description, auto_created
))
return res
async def _monitor_and_index_backup(
job_id: str,
backup_id: str,
archive_path: Path,
scope: str,
description: str,
auto_created: bool
):
while True:
await asyncio.sleep(1)
job = runner.get_job(job_id)
if not job or job.get("status") in ["completed", "failed"]:
break
if job and job.get("status") == "completed" and archive_path.exists():
size_bytes = archive_path.stat().st_size
sha256 = compute_sha256(archive_path)
files_count = 0
try:
with tarfile.open(archive_path, "r:gz") as tar:
files_count = len(tar.getmembers())
except Exception:
pass
entry = {
"id": backup_id,
"filename": archive_path.name,
"path": str(archive_path),
"scope": scope,
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"size_bytes": size_bytes,
"formatted_size": format_bytes(size_bytes),
"sha256": sha256,
"files_count": files_count,
"description": description,
"auto_created": auto_created,
"verified": True
}
index = load_backup_index()
index.insert(0, entry)
save_backup_index(index)
# Auto-prune if retention count exceeded
retention = get_setting("retention_count", 10)
prune_backups(retention)
def list_backups() -> List[Dict[str, Any]]:
index = load_backup_index()
backup_dir = get_backup_dir()
valid = []
for item in index:
fp = Path(item.get("path", ""))
if fp.exists():
valid.append(item)
# Detect any unindexed .tar.gz in local backup_dir
known_files = {item["filename"] for item in valid}
for f in backup_dir.glob("pangolin-backup_*.tar.gz"):
if f.name not in known_files:
try:
sz = f.stat().st_size
mtime = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(f.stat().st_mtime))
h = compute_sha256(f)
valid.append({
"id": f"bk_imported_{f.stem}",
"filename": f.name,
"path": str(f),
"scope": "pangolin" if "pangolin" in f.name else "full",
"created_at": mtime,
"size_bytes": sz,
"formatted_size": format_bytes(sz),
"sha256": h,
"files_count": 0,
"description": "Detected in Unraid storage",
"auto_created": False,
"verified": True
})
except Exception:
pass
valid.sort(key=lambda x: x.get("created_at", ""), reverse=True)
if len(valid) != len(index):
save_backup_index(valid)
return valid
def inspect_backup(backup_id: str) -> Dict[str, Any]:
index = load_backup_index()
target = next((b for b in index if b["id"] == backup_id), None)
if not target:
return {"success": False, "error": "Backup not found"}
filepath = Path(target["path"])
if not filepath.exists():
return {"success": False, "error": "Backup archive file not found on Unraid disk"}
members = []
try:
with tarfile.open(filepath, "r:gz") as tar:
for m in tar.getmembers():
members.append({
"name": m.name,
"size": m.size,
"formatted_size": format_bytes(m.size),
"is_dir": m.isdir(),
"mtime": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(m.mtime))
})
return {
"success": True,
"backup": target,
"total_files": len(members),
"members": members
}
except Exception as e:
return {"success": False, "error": f"Failed to inspect archive: {str(e)}"}
def verify_backup(backup_id: str) -> Dict[str, Any]:
index = load_backup_index()
target = next((b for b in index if b["id"] == backup_id), None)
if not target:
return {"success": False, "error": "Backup not found"}
filepath = Path(target["path"])
if not filepath.exists():
return {"success": False, "error": "Backup file not found on Unraid disk"}
current_hash = compute_sha256(filepath)
hash_match = (current_hash == target.get("sha256"))
tar_valid = False
try:
with tarfile.open(filepath, "r:gz") as tar:
tar.getmembers()
tar_valid = True
except Exception:
tar_valid = False
is_verified = hash_match and tar_valid
target["verified"] = is_verified
save_backup_index(index)
return {
"success": True,
"verified": is_verified,
"sha256_match": hash_match,
"tar_integrity": tar_valid,
"expected_sha256": target.get("sha256"),
"computed_sha256": current_hash
}
async def restore_backup(backup_id: str) -> Dict[str, Any]:
index = load_backup_index()
target = next((b for b in index if b["id"] == backup_id), None)
if not target:
return {"success": False, "error": "Backup not found"}
archive_path = Path(target["path"])
if not archive_path.exists():
return {"success": False, "error": "Archive file does not exist on Unraid disk"}
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 host configured."}
ssh_prefix_str = " ".join(build_ssh_prefix(settings))
scp_prefix_str = " ".join(build_scp_prefix(settings))
vps_user = settings.get("vps_user", "root")
remote_tar = f"/tmp/restore_{target['filename']}"
remote_restore_stage = f"/tmp/restore_stage_{int(time.time())}"
# Script: Upload archive to VPS -> extract and restore -> restart containers
restore_script = f"""
set -e
echo "================================================================"
echo " RESTORING BACKUP FROM UNRAID TO REMOTE VPS ({vps_host})"
echo " Archive: {target['filename']}"
echo "================================================================"
echo ""
echo "=== STEP 1: Uploading backup archive from Unraid to VPS ==="
{scp_prefix_str} "{archive_path}" "{vps_user}@{vps_host}:{remote_tar}"
echo ""
echo "=== STEP 2: Applying restore on remote VPS ==="
{ssh_prefix_str} << 'REMOTE_RESTORE_EOF'
set -e
mkdir -p "{remote_restore_stage}"
tar -xzf "{remote_tar}" -C "{remote_restore_stage}"
# Stop containers on VPS
if [ -d "{p_dir}" ]; then
echo "Stopping existing containers on VPS..."
cd "{p_dir}" && docker compose down 2>/dev/null || true
fi
# Restore Pangolin files
if [ -d "{remote_restore_stage}/pangolin" ]; then
echo "Restoring Pangolin directory files on VPS..."
mkdir -p "{p_dir}"
rsync -av "{remote_restore_stage}/pangolin/" "{p_dir}/"
fi
# Restore VPS Config if present
if [ -d "{remote_restore_stage}/vps_config" ]; then
echo "Restoring VPS system configurations..."
rsync -av "{remote_restore_stage}/vps_config/" "/"
fi
# Restart containers on VPS
if [ -f "{p_dir}/docker-compose.yml" ] || [ -f "{p_dir}/compose.yaml" ]; then
echo "Starting Pangolin containers on VPS..."
cd "{p_dir}" && docker compose up -d
docker compose ps
fi
# Cleanup VPS tmp
rm -rf "{remote_restore_stage}" "{remote_tar}"
echo "Remote VPS restore completed successfully!"
REMOTE_RESTORE_EOF
echo ""
echo "=== RESTORE OPERATION COMPLETED SUCCESSFULLY ==="
"""
res = await runner.run_command_job(
name=f"Restore to VPS ({target['filename']})",
cmd=restore_script,
job_type="restore",
description=f"Restore backup {backup_id} from Unraid to remote VPS ({vps_host})"
)
return res
def delete_backup(backup_id: str) -> Dict[str, Any]:
index = load_backup_index()
target = next((b for b in index if b["id"] == backup_id), None)
if not target:
return {"success": False, "error": "Backup not found"}
try:
p = Path(target["path"])
if p.exists():
p.unlink()
except Exception as e:
print(f"Error removing local file {target['path']}: {e}")
new_index = [b for b in index if b["id"] != backup_id]
save_backup_index(new_index)
return {"success": True, "message": "Backup deleted from Unraid server"}
def prune_backups(keep_count: int = 10) -> int:
index = load_backup_index()
if len(index) <= keep_count:
return 0
to_prune = index[keep_count:]
kept = index[:keep_count]
pruned = 0
for item in to_prune:
try:
p = Path(item["path"])
if p.exists():
p.unlink()
pruned += 1
except Exception:
pass
save_backup_index(kept)
return pruned
+111
View File
@@ -0,0 +1,111 @@
import os
import json
import secrets
from pathlib import Path
from typing import Dict, Any
# Project base directory: can be overridden via APP_DIR or auto-detected from file location
DEFAULT_APP_DIR = Path(__file__).resolve().parent.parent
BASE_DIR = Path(os.environ.get("APP_DIR", str(DEFAULT_APP_DIR)))
# Persistent data directory: holds settings, SSH keys, job history, and backup index
DATA_DIR = Path(os.environ.get("DATA_DIR", str(BASE_DIR / "data")))
CONFIG_FILE = DATA_DIR / "settings.json"
BACKUP_INDEX_FILE = DATA_DIR / "backups_index.json"
JOBS_FILE = DATA_DIR / "jobs_history.json"
DEFAULT_KEY_FILE = DATA_DIR / "id_ed25519"
DEFAULT_PUB_KEY_FILE = DATA_DIR / "id_ed25519.pub"
def get_default_backup_dir() -> str:
if "BACKUP_DIR" in os.environ and os.environ["BACKUP_DIR"].strip():
return os.environ["BACKUP_DIR"].strip()
if os.path.exists("/backups"):
return "/backups"
if os.path.exists("/mnt/user"):
return "/mnt/user/backups/pangolin"
if os.path.exists("/var/backups"):
return "/var/backups/pangolin"
return str(DATA_DIR / "backups")
DEFAULT_LOCAL_BACKUP = get_default_backup_dir()
DEFAULT_SETTINGS = {
"auth_enabled": True,
"admin_password_hash": "",
"admin_password_salt": "",
"jwt_secret": secrets.token_hex(32),
# Remote VPS Configuration
"vps_host": "", # IP or Hostname of the Pangolin VPS
"vps_port": 22, # SSH Port
"vps_user": "root", # SSH Username
"vps_auth_method": "key", # "key" or "password"
"vps_ssh_key_path": str(DEFAULT_KEY_FILE),
"vps_ssh_password": "", # SSH Password (if method == "password")
"vps_pangolin_dir": "/opt/pangolin", # Path to Pangolin on remote VPS
# Local Unraid Storage & Retention
"backup_dir": DEFAULT_LOCAL_BACKUP,
"retention_count": 10,
# Automation & Scheduler
"scheduled_backup_enabled": True,
"scheduled_backup_cron": "0 3 * * *",
"scheduled_backup_scope": "pangolin",
"scheduled_update_check_enabled": True,
# Webhooks
"webhook_url": "",
"webhook_type": "discord",
"telegram_bot_token": "",
"telegram_chat_id": "",
"notify_on_backup": True,
"notify_on_update": True,
# App daemon
"app_port": int(os.environ.get("PORT", 8888)),
"app_host": os.environ.get("HOST", "0.0.0.0")
}
def load_settings() -> Dict[str, Any]:
DATA_DIR.mkdir(parents=True, exist_ok=True)
if not CONFIG_FILE.exists():
save_settings(DEFAULT_SETTINGS)
return DEFAULT_SETTINGS.copy()
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
for k, v in DEFAULT_SETTINGS.items():
if k not in data:
data[k] = v
# Auto-heal SSH key path if configured path does not exist but default key does
cur_key_path = data.get("vps_ssh_key_path", "")
if (not cur_key_path or not os.path.exists(cur_key_path)) and DEFAULT_KEY_FILE.exists():
data["vps_ssh_key_path"] = str(DEFAULT_KEY_FILE)
# Auto-heal backup_dir if set to old non-existent path and /backups is mounted in container
cur_backup_dir = data.get("backup_dir", "")
if os.path.exists("/backups") and (not cur_backup_dir or not os.path.exists(cur_backup_dir)):
data["backup_dir"] = "/backups"
return data
except Exception as e:
print(f"Error loading settings: {e}. Using defaults.")
return DEFAULT_SETTINGS.copy()
def save_settings(settings: Dict[str, Any]) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(settings, f, indent=2)
def get_setting(key: str, default: Any = None) -> Any:
settings = load_settings()
return settings.get(key, default)
def update_setting(key: str, value: Any) -> None:
settings = load_settings()
settings[key] = value
save_settings(settings)
+422
View File
@@ -0,0 +1,422 @@
import asyncio
import os
import shutil
import sys
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Dict, Any, Optional
# Ensure project root is in sys.path so backend modules can be imported directly
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from fastapi import (
FastAPI, HTTPException, Depends, Request, Response,
WebSocket, WebSocketDisconnect, UploadFile, File, Form, status
)
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from backend.config import (
load_settings, save_settings, get_setting, update_setting,
BASE_DIR, DATA_DIR, DEFAULT_KEY_FILE, DEFAULT_PUB_KEY_FILE
)
from backend.auth import (
require_auth, hash_password, verify_password,
create_session_token, is_setup_required
)
from backend.system import (
get_system_stats, check_upgradable_packages,
trigger_system_upgrade, trigger_system_reboot
)
from backend.pangolin import (
get_pangolin_stack_status,
trigger_pangolin_update, container_action,
get_container_logs, get_stack_config_files,
save_stack_config_file
)
from backend.backup import (
list_backups, run_backup_task, inspect_backup,
verify_backup, restore_backup, delete_backup,
get_backup_dir, load_backup_index, save_backup_index,
compute_sha256
)
from backend.remote import (
get_public_key, ensure_ssh_key, test_vps_connection
)
from backend.runner import runner
from backend.scheduler import start_scheduler, shutdown_scheduler, reload_scheduler
from backend.notifier import test_webhook, send_notification
@asynccontextmanager
async def lifespan(app: FastAPI):
print("[APP] Initializing Pangolin Sentinel (Unraid -> VPS)...")
ensure_ssh_key()
start_scheduler()
yield
print("[APP] Shutting down Pangolin Sentinel...")
shutdown_scheduler()
app = FastAPI(
title="Pangolin Sentinel — Unraid to VPS Hub",
description="Full web app on Unraid for updating and backing up remote Pangolin VPS",
version="2.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
FRONTEND_DIR = BASE_DIR / "frontend"
ASSETS_DIR = FRONTEND_DIR / "assets"
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/assets", StaticFiles(directory=str(ASSETS_DIR)), name="assets")
# ----------------- Auth Endpoints -----------------
class SetupRequest(BaseModel):
password: str
class LoginRequest(BaseModel):
password: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
@app.get("/api/auth/status")
async def get_auth_status():
settings = load_settings()
return {
"auth_enabled": settings.get("auth_enabled", True),
"setup_required": is_setup_required()
}
@app.post("/api/auth/setup")
async def initial_setup(req: SetupRequest, response: Response):
if not is_setup_required():
raise HTTPException(status_code=400, detail="Admin password is already configured.")
if len(req.password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters.")
pwd_hash, salt = hash_password(req.password)
update_setting("admin_password_hash", pwd_hash)
update_setting("admin_password_salt", salt)
token = create_session_token()
response.set_cookie("session_token", token, httponly=True, max_age=86400 * 7, samesite="lax")
return {"success": True, "token": token}
@app.post("/api/auth/login")
async def login(req: LoginRequest, response: Response):
settings = load_settings()
if not settings.get("auth_enabled", True):
token = create_session_token()
return {"success": True, "token": token}
pwd_hash = settings.get("admin_password_hash", "")
salt = settings.get("admin_password_salt", "")
if not pwd_hash:
raise HTTPException(status_code=400, detail="Setup required. Please set admin password first.")
if not verify_password(req.password, pwd_hash, salt):
raise HTTPException(status_code=401, detail="Invalid password.")
token = create_session_token()
response.set_cookie("session_token", token, httponly=True, max_age=86400 * 7, samesite="lax")
return {"success": True, "token": token}
@app.post("/api/auth/logout")
async def logout(response: Response):
response.delete_cookie("session_token")
return {"success": True}
@app.post("/api/auth/change-password", dependencies=[Depends(require_auth)])
async def change_password(req: ChangePasswordRequest):
settings = load_settings()
pwd_hash = settings.get("admin_password_hash", "")
salt = settings.get("admin_password_salt", "")
if not verify_password(req.old_password, pwd_hash, salt):
raise HTTPException(status_code=400, detail="Incorrect current password.")
if len(req.new_password) < 6:
raise HTTPException(status_code=400, detail="New password must be at least 6 characters.")
new_hash, new_salt = hash_password(req.new_password)
update_setting("admin_password_hash", new_hash)
update_setting("admin_password_salt", new_salt)
return {"success": True, "message": "Password updated successfully."}
# ----------------- Remote VPS Connection Endpoints -----------------
@app.get("/api/remote/public-key", dependencies=[Depends(require_auth)])
async def api_get_public_key():
pub = ensure_ssh_key()
return {
"public_key": pub,
"key_type": "ed25519",
"command_hint": f'echo "{pub}" >> ~/.ssh/authorized_keys'
}
class TestConnectionRequest(BaseModel):
settings: Optional[Dict[str, Any]] = None
@app.post("/api/remote/test-connection", dependencies=[Depends(require_auth)])
async def api_test_vps_connection(req: TestConnectionRequest):
res = await test_vps_connection(req.settings)
return res
# ----------------- System Endpoints (Remote VPS) -----------------
@app.get("/api/system/stats", dependencies=[Depends(require_auth)])
async def api_system_stats():
return await get_system_stats()
@app.get("/api/system/updates", dependencies=[Depends(require_auth)])
async def api_check_updates():
return await check_upgradable_packages()
class UpgradeRequest(BaseModel):
dist_upgrade: bool = False
@app.post("/api/system/upgrade", dependencies=[Depends(require_auth)])
async def api_trigger_upgrade(req: UpgradeRequest):
res = await trigger_system_upgrade(req.dist_upgrade)
if not res.get("success"):
raise HTTPException(status_code=409, detail=res.get("error"))
return res
@app.post("/api/system/reboot", dependencies=[Depends(require_auth)])
async def api_trigger_reboot():
res = await trigger_system_reboot()
return res
# ----------------- Pangolin Stack Endpoints (Remote VPS) -----------------
@app.get("/api/pangolin/status", dependencies=[Depends(require_auth)])
async def api_pangolin_status():
return await get_pangolin_stack_status()
@app.post("/api/pangolin/update", dependencies=[Depends(require_auth)])
async def api_trigger_pangolin_update():
res = await trigger_pangolin_update()
if not res.get("success"):
raise HTTPException(status_code=400, detail=res.get("error"))
return res
class ContainerActionRequest(BaseModel):
action: str
@app.post("/api/pangolin/containers/{name}/action", dependencies=[Depends(require_auth)])
async def api_container_action(name: str, req: ContainerActionRequest):
res = await container_action(name, req.action)
if not res.get("success"):
raise HTTPException(status_code=400, detail=res.get("error"))
return res
@app.get("/api/pangolin/containers/{name}/logs", dependencies=[Depends(require_auth)])
async def api_container_logs(name: str, lines: int = 150):
return await get_container_logs(name, lines)
@app.get("/api/pangolin/files", dependencies=[Depends(require_auth)])
async def api_get_stack_files():
return await get_stack_config_files()
class SaveFileRequest(BaseModel):
filename: str
content: str
@app.post("/api/pangolin/files", dependencies=[Depends(require_auth)])
async def api_save_stack_file(req: SaveFileRequest):
res = await save_stack_config_file(req.filename, req.content)
if not res.get("success"):
raise HTTPException(status_code=400, detail=res.get("error"))
return res
# ----------------- Backups Endpoints (Stored on Unraid) -----------------
@app.get("/api/backups", dependencies=[Depends(require_auth)])
async def api_list_backups():
return list_backups()
class CreateBackupRequest(BaseModel):
scope: str = "pangolin"
description: str = ""
@app.post("/api/backups", dependencies=[Depends(require_auth)])
async def api_create_backup(req: CreateBackupRequest):
res = await run_backup_task(
scope=req.scope,
description=req.description or "Manual backup pulled from VPS to Unraid",
auto_created=False
)
if not res.get("success"):
raise HTTPException(status_code=409, detail=res.get("error"))
return res
@app.get("/api/backups/{backup_id}/inspect", dependencies=[Depends(require_auth)])
async def api_inspect_backup(backup_id: str):
res = inspect_backup(backup_id)
if not res.get("success"):
raise HTTPException(status_code=404, detail=res.get("error"))
return res
@app.post("/api/backups/{backup_id}/verify", dependencies=[Depends(require_auth)])
async def api_verify_backup(backup_id: str):
res = verify_backup(backup_id)
if not res.get("success"):
raise HTTPException(status_code=404, detail=res.get("error"))
return res
@app.post("/api/backups/{backup_id}/restore", dependencies=[Depends(require_auth)])
async def api_restore_backup(backup_id: str):
res = await restore_backup(backup_id)
if not res.get("success"):
raise HTTPException(status_code=409, detail=res.get("error"))
return res
@app.delete("/api/backups/{backup_id}", dependencies=[Depends(require_auth)])
async def api_delete_backup(backup_id: str):
res = delete_backup(backup_id)
if not res.get("success"):
raise HTTPException(status_code=404, detail=res.get("error"))
return res
@app.get("/api/backups/{backup_id}/download", dependencies=[Depends(require_auth)])
async def api_download_backup(backup_id: str):
index = load_backup_index()
target = next((b for b in index if b["id"] == backup_id), None)
if not target:
raise HTTPException(status_code=404, detail="Backup not found")
filepath = Path(target["path"])
if not filepath.exists():
raise HTTPException(status_code=404, detail="File does not exist on disk")
return FileResponse(
path=str(filepath),
filename=target["filename"],
media_type="application/gzip"
)
@app.post("/api/backups/upload", dependencies=[Depends(require_auth)])
async def api_upload_backup(file: UploadFile = File(...)):
if not file.filename.endswith((".tar.gz", ".tgz")):
raise HTTPException(status_code=400, detail="Only .tar.gz archives are supported.")
bdir = get_backup_dir()
safe_filename = Path(file.filename).name
dest_path = bdir / safe_filename
if dest_path.exists():
dest_path = bdir / f"{dest_path.stem}_{int(time.time())}{dest_path.suffix}"
with open(dest_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
size = dest_path.stat().st_size
sha256 = compute_sha256(dest_path)
index = load_backup_index()
backup_id = f"bk_upload_{int(time.time())}"
entry = {
"id": backup_id,
"filename": dest_path.name,
"path": str(dest_path),
"scope": "uploaded",
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"size_bytes": size,
"formatted_size": f"{size / (1024*1024):.2f} MB",
"sha256": sha256,
"files_count": 0,
"description": f"Uploaded to Unraid: {file.filename}",
"auto_created": False,
"verified": True
}
index.insert(0, entry)
save_backup_index(index)
return {"success": True, "backup": entry}
# ----------------- Settings Endpoints -----------------
@app.get("/api/settings", dependencies=[Depends(require_auth)])
async def api_get_settings():
s = load_settings().copy()
s.pop("admin_password_hash", None)
s.pop("admin_password_salt", None)
s.pop("jwt_secret", None)
return s
class UpdateSettingsRequest(BaseModel):
settings: Dict[str, Any]
@app.post("/api/settings", dependencies=[Depends(require_auth)])
async def api_update_settings(req: UpdateSettingsRequest):
allowed_keys = [
"auth_enabled", "vps_host", "vps_port", "vps_user", "vps_auth_method",
"vps_ssh_key_path", "vps_ssh_password", "vps_pangolin_dir",
"backup_dir", "retention_count",
"scheduled_backup_enabled", "scheduled_backup_cron", "scheduled_backup_scope",
"scheduled_update_check_enabled", "webhook_url", "webhook_type",
"telegram_bot_token", "telegram_chat_id", "notify_on_backup", "notify_on_update"
]
cur = load_settings()
for k, v in req.settings.items():
if k in allowed_keys:
cur[k] = v
save_settings(cur)
reload_scheduler()
return {"success": True, "settings": cur}
@app.post("/api/notifications/test", dependencies=[Depends(require_auth)])
async def api_test_notification():
return await test_webhook()
# ----------------- Jobs & WebSocket -----------------
@app.get("/api/jobs", dependencies=[Depends(require_auth)])
async def api_list_jobs(limit: int = 25):
return runner.list_jobs(limit)
@app.get("/api/jobs/{job_id}", dependencies=[Depends(require_auth)])
async def api_get_job(job_id: str):
job = runner.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@app.websocket("/ws/jobs")
async def websocket_jobs(websocket: WebSocket):
await websocket.accept()
await runner.register_global(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
runner.unregister_global(websocket)
except Exception:
runner.unregister_global(websocket)
# ----------------- SPA Frontend Route -----------------
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
index_path = FRONTEND_DIR / "index.html"
if not index_path.exists():
return HTMLResponse("<h1>Pangolin Sentinel is initializing...</h1>")
return FileResponse(str(index_path))
if __name__ == "__main__":
import uvicorn
port = int(os.environ.get("PORT", get_setting("app_port", 8888)))
host = os.environ.get("HOST", get_setting("app_host", "0.0.0.0"))
uvicorn.run("backend.main:app", host=host, port=port, reload=False)
+95
View File
@@ -0,0 +1,95 @@
import asyncio
import time
import httpx
from typing import Dict, Any, Optional
from backend.config import get_setting
async def send_notification(
title: str,
message: str,
status: str = "info", # "success", "error", "warning", "info"
details: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
webhook_url = get_setting("webhook_url", "")
webhook_type = get_setting("webhook_type", "discord")
bot_token = get_setting("telegram_bot_token", "")
chat_id = get_setting("telegram_chat_id", "")
if webhook_type == "telegram":
if not bot_token or not chat_id:
return {"success": False, "error": "Telegram Bot Token and Chat ID required"}
elif not webhook_url:
return {"success": False, "error": "Webhook URL not configured"}
color_map = {
"success": 0x10B981, # Emerald green
"error": 0xEF4444, # Red
"warning": 0xF59E0B, # Amber
"info": 0x3B82F6 # Blue
}
color = color_map.get(status, 0x3B82F6)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
if webhook_type == "discord":
embed = {
"title": title,
"description": message,
"color": color,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"footer": {"text": "Pangolin VPS Sentinel"}
}
if details:
fields = []
for k, v in details.items():
fields.append({"name": str(k).capitalize(), "value": str(v), "inline": True})
embed["fields"] = fields
payload = {
"username": "Pangolin Sentinel",
"embeds": [embed]
}
resp = await client.post(webhook_url, json=payload)
return {"success": resp.is_success, "status_code": resp.status_code, "text": resp.text}
elif webhook_type == "telegram":
tg_url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
icon = "✅" if status == "success" else "❌" if status == "error" else "ℹ️"
text = f"<b>{icon} {title}</b>\n\n{message}\n"
if details:
text += "\n"
for k, v in details.items():
text += f"• <b>{k.capitalize()}:</b> {v}\n"
resp = await client.post(tg_url, json={
"chat_id": chat_id,
"text": text,
"parse_mode": "HTML"
})
return {"success": resp.is_success, "status_code": resp.status_code, "text": resp.text}
else: # Generic webhook
payload = {
"event": "pangolin_event",
"title": title,
"message": message,
"status": status,
"details": details or {},
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
}
resp = await client.post(webhook_url, json=payload)
return {"success": resp.is_success, "status_code": resp.status_code, "text": resp.text}
except Exception as e:
return {"success": False, "error": str(e)}
async def test_webhook() -> Dict[str, Any]:
return await send_notification(
title="🔔 Test Notification from Pangolin Sentinel",
message="Your webhook configuration is active and working properly on your Pangolin VPS.",
status="info",
details={
"vps": get_setting("app_host", "Pangolin-VPS"),
"time": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
}
)
+239
View File
@@ -0,0 +1,239 @@
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"""
[ -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": 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,
"error": stderr.strip() or "SSH connection to VPS failed."
}
lines = stdout.splitlines()
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": p_dir,
"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}"}
+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!"
}
+215
View File
@@ -0,0 +1,215 @@
import asyncio
import os
import json
import time
import secrets
from typing import Dict, List, Optional, Any, Set
from fastapi import WebSocket
from backend.config import JOBS_FILE
class JobManager:
def __init__(self):
self.active_jobs: Dict[str, Dict[str, Any]] = {}
self.subscribers: Dict[str, Set[WebSocket]] = {}
self.global_subscribers: Set[WebSocket] = set()
self.lock = asyncio.Lock()
self.type_locks: Dict[str, asyncio.Lock] = {
"backup": asyncio.Lock(),
"restore": asyncio.Lock(),
"update_pangolin": asyncio.Lock(),
"update_system": asyncio.Lock(),
}
def _load_history(self) -> List[Dict[str, Any]]:
if not JOBS_FILE.exists():
return []
try:
with open(JOBS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
def _save_history(self, history: List[Dict[str, Any]]) -> None:
try:
with open(JOBS_FILE, "w", encoding="utf-8") as f:
json.dump(history[:50], f, indent=2)
except Exception as e:
print(f"Error saving jobs history: {e}")
def list_jobs(self, limit: int = 25) -> List[Dict[str, Any]]:
history = self._load_history()
# Merge active jobs at the beginning
active_list = list(self.active_jobs.values())
seen_ids = {j["id"] for j in active_list}
merged = active_list + [j for j in history if j["id"] not in seen_ids]
merged.sort(key=lambda x: x.get("start_time", 0), reverse=True)
return merged[:limit]
def get_job(self, job_id: str) -> Optional[Dict[str, Any]]:
if job_id in self.active_jobs:
return self.active_jobs[job_id]
history = self._load_history()
for j in history:
if j["id"] == job_id:
return j
return None
async def register_subscriber(self, job_id: str, ws: WebSocket):
if job_id not in self.subscribers:
self.subscribers[job_id] = set()
self.subscribers[job_id].add(ws)
def unregister_subscriber(self, job_id: str, ws: WebSocket):
if job_id in self.subscribers:
self.subscribers[job_id].discard(ws)
if not self.subscribers[job_id]:
del self.subscribers[job_id]
async def register_global(self, ws: WebSocket):
self.global_subscribers.add(ws)
def unregister_global(self, ws: WebSocket):
self.global_subscribers.discard(ws)
async def broadcast(self, job_id: str, message: Dict[str, Any]):
# Broadcast to specific job subscribers
if job_id in self.subscribers:
dead = set()
for ws in list(self.subscribers[job_id]):
try:
await ws.send_json(message)
except Exception:
dead.add(ws)
for ws in dead:
self.subscribers[job_id].discard(ws)
# Broadcast to global subscribers
dead_global = set()
for ws in list(self.global_subscribers):
try:
await ws.send_json(message)
except Exception:
dead_global.add(ws)
for ws in dead_global:
self.global_subscribers.discard(ws)
async def run_command_job(
self,
name: str,
cmd: List[str] | str,
job_type: str = "general",
env: Optional[Dict[str, str]] = None,
cwd: Optional[str] = None,
description: str = ""
) -> Dict[str, Any]:
lock = self.type_locks.get(job_type)
if lock and lock.locked():
return {
"success": False,
"error": f"A {job_type} task is already running in the background. Please wait for it to complete."
}
job_id = f"job_{int(time.time())}_{secrets.token_hex(4)}"
job = {
"id": job_id,
"name": name,
"type": job_type,
"description": description,
"cmd": cmd if isinstance(cmd, str) else " ".join(cmd),
"status": "running",
"start_time": time.time(),
"end_time": None,
"duration": None,
"exit_code": None,
"logs": []
}
self.active_jobs[job_id] = job
await self.broadcast(job_id, {"type": "job_started", "job": job})
# Run in background asyncio task
asyncio.create_task(self._execute_process(job_id, cmd, env, cwd, lock))
return {"success": True, "job_id": job_id, "job": job}
async def _execute_process(
self,
job_id: str,
cmd: List[str] | str,
env: Optional[Dict[str, str]],
cwd: Optional[str],
lock: Optional[asyncio.Lock]
):
if lock:
await lock.acquire()
job = self.active_jobs[job_id]
run_env = os.environ.copy()
run_env["DEBIAN_FRONTEND"] = "noninteractive"
run_env["TERM"] = "xterm-256color"
run_env["COLUMNS"] = "120"
if env:
run_env.update(env)
try:
if isinstance(cmd, list):
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env=run_env,
cwd=cwd
)
else:
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env=run_env,
cwd=cwd,
executable="/bin/bash" if os.path.exists("/bin/bash") else None
)
while True:
line = await proc.stdout.readline()
if not line:
break
text = line.decode('utf-8', errors='replace').rstrip('\r\n')
timestamp = time.strftime("%H:%M:%S")
log_entry = f"[{timestamp}] {text}"
job["logs"].append(log_entry)
# Keep up to 2000 lines
if len(job["logs"]) > 2000:
job["logs"] = job["logs"][-2000:]
await self.broadcast(job_id, {"type": "log", "job_id": job_id, "data": log_entry})
await proc.wait()
exit_code = proc.returncode
job["exit_code"] = exit_code
job["status"] = "completed" if exit_code == 0 else "failed"
except Exception as e:
err_msg = f"Execution error: {str(e)}"
job["logs"].append(f"[{time.strftime('%H:%M:%S')}] ERROR: {err_msg}")
job["status"] = "failed"
job["exit_code"] = -1
await self.broadcast(job_id, {"type": "log", "job_id": job_id, "data": err_msg})
finally:
job["end_time"] = time.time()
job["duration"] = round(job["end_time"] - job["start_time"], 1)
await self.broadcast(job_id, {"type": "job_finished", "job": job})
# Save to history file
history = self._load_history()
history.insert(0, job.copy())
self._save_history(history)
# Clean from active_jobs after a short delay so clients can finish reading
await asyncio.sleep(5)
if job_id in self.active_jobs:
del self.active_jobs[job_id]
if lock:
lock.release()
runner = JobManager()
+108
View File
@@ -0,0 +1,108 @@
import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from backend.config import get_setting, load_settings
from backend.backup import run_backup_task
from backend.system import check_upgradable_packages
from backend.notifier import send_notification
scheduler = AsyncIOScheduler()
async def scheduled_backup_job():
print("[SCHEDULER] Triggering scheduled automated backup...")
settings = load_settings()
scope = settings.get("scheduled_backup_scope", "pangolin")
try:
res = await run_backup_task(
scope=scope,
description="Automated scheduled backup",
auto_created=True
)
if settings.get("notify_on_backup"):
await send_notification(
title=f"Automated Backup Started ({scope.upper()})",
message=f"A scheduled backup job has been initiated.",
status="info"
)
except Exception as e:
print(f"[SCHEDULER] Error during scheduled backup: {e}")
if settings.get("notify_on_backup"):
await send_notification(
title="Automated Backup Failed",
message=f"Scheduled backup failed to start: {str(e)}",
status="error"
)
async def scheduled_update_check_job():
print("[SCHEDULER] Checking for available updates...")
settings = load_settings()
if not settings.get("scheduled_update_check_enabled", True):
return
try:
pkgs = await check_upgradable_packages()
total = pkgs.get("total", 0)
sec = pkgs.get("security_count", 0)
if total > 0 and settings.get("notify_on_update"):
await send_notification(
title=f"📦 VPS Updates Available ({total} packages)",
message=f"There are {total} upgradable package(s) available, including {sec} security update(s).",
status="warning" if sec > 0 else "info",
details={
"total_upgrades": total,
"security_updates": sec
}
)
except Exception as e:
print(f"[SCHEDULER] Error during update check: {e}")
def reload_scheduler():
settings = load_settings()
# Remove existing jobs if any
for job_id in ["scheduled_backup", "scheduled_update_check"]:
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
# 1. Scheduled Backup
if settings.get("scheduled_backup_enabled", True):
cron_expr = settings.get("scheduled_backup_cron", "0 3 * * *")
parts = cron_expr.strip().split()
if len(parts) == 5:
trigger = CronTrigger(
minute=parts[0],
hour=parts[1],
day=parts[2],
month=parts[3],
day_of_week=parts[4]
)
scheduler.add_job(
scheduled_backup_job,
trigger=trigger,
id="scheduled_backup",
replace_existing=True
)
print(f"[SCHEDULER] Scheduled backup registered with cron: {cron_expr}")
# 2. Scheduled Update Check (every 12 hours)
if settings.get("scheduled_update_check_enabled", True):
scheduler.add_job(
scheduled_update_check_job,
trigger=IntervalTrigger(hours=12),
id="scheduled_update_check",
replace_existing=True
)
print("[SCHEDULER] Scheduled update checks registered (every 12 hours).")
def start_scheduler():
if not scheduler.running:
reload_scheduler()
scheduler.start()
print("[SCHEDULER] Scheduler started.")
def shutdown_scheduler():
if scheduler.running:
scheduler.shutdown()
+273
View File
@@ -0,0 +1,273 @@
import asyncio
import json
import os
import platform
import re
import shutil
import time
from pathlib import Path
from typing import Dict, Any, List, Optional
import psutil
from backend.runner import runner
from backend.config import get_setting, load_settings, get_default_backup_dir
from backend.remote import run_remote_quick, build_ssh_prefix
def format_bytes(size: int | float) -> str:
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size < 1024.0:
return f"{size:.2f} {unit}"
size /= 1024.0
return f"{size:.2f} PB"
def format_uptime(seconds: float) -> str:
days = int(seconds // (24 * 3600))
seconds %= (24 * 3600)
hours = int(seconds // 3600)
seconds %= 3600
minutes = int(seconds // 60)
parts = []
if days > 0:
parts.append(f"{days}d")
if hours > 0 or days > 0:
parts.append(f"{hours}h")
parts.append(f"{minutes}m")
return " ".join(parts)
async def get_system_stats() -> Dict[str, Any]:
settings = load_settings()
vps_host = settings.get("vps_host", "").strip()
# Local Storage Info (Unraid Server or Docker Backup Volume)
configured_dir = settings.get("backup_dir", "").strip() or get_default_backup_dir()
backup_path = configured_dir
unraid_disk = None
try:
p = Path(backup_path)
if not p.exists():
p.mkdir(parents=True, exist_ok=True)
bd = psutil.disk_usage(str(p))
unraid_disk = {
"path": str(p),
"total": bd.total,
"used": bd.used,
"free": bd.free,
"percent": bd.percent,
"formatted_total": format_bytes(bd.total),
"formatted_used": format_bytes(bd.used),
"formatted_free": format_bytes(bd.free)
}
except Exception:
pass
# If no VPS configured yet, return local info and indicator
if not vps_host:
local_cpu = psutil.cpu_percent(interval=0.05)
local_mem = psutil.virtual_memory()
local_uptime = time.time() - psutil.boot_time()
return {
"vps_configured": False,
"hostname": "No VPS Configured (Local Host)",
"os": "Unraid / Docker Host",
"kernel": platform.release(),
"uptime_formatted": format_uptime(local_uptime),
"cpu": {"percent": local_cpu, "cores": psutil.cpu_count(logical=True), "load_avg": [0, 0, 0]},
"memory": {
"percent": local_mem.percent,
"formatted_total": format_bytes(local_mem.total),
"formatted_used": format_bytes(local_mem.used),
"formatted_available": format_bytes(local_mem.available)
},
"disk": unraid_disk or {"percent": 0, "formatted_used": "0 B", "formatted_total": "0 B", "formatted_free": "0 B"},
"unraid_disk": unraid_disk,
"reboot_required": False,
"reboot_packages": []
}
# Gather metrics from Remote VPS via SSH
remote_script = """
uname -n
cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2 | tr -d '"'
uname -r
cat /proc/uptime | awk '{print $1}'
grep -c ^processor /proc/cpuinfo
cat /proc/loadavg | awk '{print $1,$2,$3}'
free -b | grep Mem: | awk '{print $2,$3,$7}'
df -B1 / | tail -n 1 | awk '{print $2,$3,$4}'
[ -f /var/run/reboot-required ] && echo "REBOOT_YES" || echo "REBOOT_NO"
[ -f /var/run/reboot-required.pkgs ] && cat /var/run/reboot-required.pkgs || true
"""
ret, stdout, stderr = await run_remote_quick(remote_script, settings)
if ret != 0:
return {
"vps_configured": True,
"vps_connected": False,
"hostname": vps_host,
"os": "Remote VPS (Unreachable)",
"kernel": "SSH Error",
"uptime_formatted": "Offline",
"error": stderr.strip() or "Failed to connect over SSH",
"cpu": {"percent": 0, "cores": 1, "load_avg": [0, 0, 0]},
"memory": {"percent": 0, "formatted_total": "0 B", "formatted_used": "0 B", "formatted_available": "0 B"},
"disk": {"percent": 0, "formatted_total": "0 B", "formatted_used": "0 B", "formatted_free": "0 B"},
"unraid_disk": unraid_disk,
"reboot_required": False,
"reboot_packages": []
}
lines = [l.strip() for l in stdout.splitlines() if l.strip()]
try:
r_host = lines[0]
r_os = lines[1]
r_kernel = lines[2]
r_uptime_sec = float(lines[3])
r_cores = int(lines[4])
r_load = [float(x) for x in lines[5].split()[:3]]
mem_parts = [int(x) for x in lines[6].split()]
mem_total, mem_used, mem_avail = mem_parts[0], mem_parts[1], mem_parts[2]
mem_pct = round((mem_used / mem_total) * 100, 1) if mem_total else 0
disk_parts = [int(x) for x in lines[7].split()]
disk_total, disk_used, disk_avail = disk_parts[0], disk_parts[1], disk_parts[2]
disk_pct = round((disk_used / disk_total) * 100, 1) if disk_total else 0
reboot_req = "REBOOT_YES" in stdout
reboot_pkgs = []
capture_pkgs = False
for l in lines:
if capture_pkgs and not l.startswith("REBOOT_"):
reboot_pkgs.append(l)
if l == "REBOOT_YES":
capture_pkgs = True
return {
"vps_configured": True,
"vps_connected": True,
"hostname": r_host,
"os": r_os,
"kernel": r_kernel,
"uptime_formatted": format_uptime(r_uptime_sec),
"cpu": {
"percent": min(100, round((r_load[0] / max(1, r_cores)) * 100, 1)),
"cores": r_cores,
"load_avg": r_load
},
"memory": {
"total": mem_total,
"used": mem_used,
"available": mem_avail,
"percent": mem_pct,
"formatted_total": format_bytes(mem_total),
"formatted_used": format_bytes(mem_used),
"formatted_available": format_bytes(mem_avail)
},
"disk": {
"total": disk_total,
"used": disk_used,
"free": disk_avail,
"percent": disk_pct,
"formatted_total": format_bytes(disk_total),
"formatted_used": format_bytes(disk_used),
"formatted_free": format_bytes(disk_avail)
},
"unraid_disk": unraid_disk,
"reboot_required": reboot_req,
"reboot_packages": reboot_pkgs
}
except Exception as e:
return {
"vps_configured": True,
"vps_connected": True,
"hostname": vps_host,
"os": "Linux VPS",
"kernel": "Connected",
"uptime_formatted": "Connected",
"cpu": {"percent": 0, "cores": 1, "load_avg": [0, 0, 0]},
"memory": {"percent": 0, "formatted_total": "0 B", "formatted_used": "0 B", "formatted_available": "0 B"},
"disk": {"percent": 0, "formatted_total": "0 B", "formatted_used": "0 B", "formatted_free": "0 B"},
"unraid_disk": unraid_disk,
"reboot_required": False,
"reboot_packages": []
}
async def check_upgradable_packages() -> Dict[str, Any]:
settings = load_settings()
vps_host = settings.get("vps_host", "").strip()
if not vps_host:
return {"total": 0, "security_count": 0, "packages": [], "message": "No VPS configured."}
# Run apt update silently on the VPS
await run_remote_quick("apt-get update -qq", settings)
# Run apt list --upgradable on the VPS
ret, stdout, _ = await run_remote_quick("apt list --upgradable 2>/dev/null", settings)
packages: List[Dict[str, str]] = []
security_count = 0
if ret == 0:
lines = stdout.splitlines()
pattern = re.compile(r"^([^/]+)/([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+\[upgradable from:\s+([^\]]+)\]")
for line in lines:
line = line.strip()
if not line or line.startswith("Listing..."):
continue
match = pattern.match(line)
if match:
pkg_name, repo, new_ver, arch, cur_ver = match.groups()
is_sec = "security" in repo.lower()
if is_sec:
security_count += 1
packages.append({
"name": pkg_name,
"repository": repo,
"current_version": cur_ver,
"new_version": new_ver,
"arch": arch,
"is_security": is_sec
})
return {
"total": len(packages),
"security_count": security_count,
"packages": packages
}
async def trigger_system_upgrade(dist_upgrade: bool = False) -> Dict[str, Any]:
settings = load_settings()
vps_host = settings.get("vps_host", "").strip()
if not vps_host:
return {"success": False, "error": "No VPS configured. Set VPS Host in Settings."}
cmd_type = "dist-upgrade" if dist_upgrade else "upgrade"
ssh_prefix = build_ssh_prefix(settings)
# Remote upgrade command with proper noninteractive env
remote_cmd = f"DEBIAN_FRONTEND=noninteractive apt-get -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold {cmd_type} -y"
full_cmd = ssh_prefix + [remote_cmd]
res = await runner.run_command_job(
name=f"Remote VPS Packages {cmd_type.capitalize()}",
cmd=full_cmd,
job_type="update_system",
description=f"Automated package upgrade on remote VPS ({vps_host}) via apt-get {cmd_type} -y"
)
return res
async def trigger_system_reboot() -> Dict[str, Any]:
settings = load_settings()
vps_host = settings.get("vps_host", "").strip()
if not vps_host:
return {"success": False, "error": "No VPS configured."}
ssh_prefix = build_ssh_prefix(settings)
remote_cmd = "shutdown -r +1 'Reboot initiated from Unraid Pangolin Sentinel'"
full_cmd = ssh_prefix + [remote_cmd]
res = await runner.run_command_job(
name=f"Remote VPS Reboot ({vps_host})",
cmd=full_cmd,
job_type="general",
description="Initiate safe remote VPS reboot scheduled in 1 minute"
)
return res
View File
Whitespace-only changes.
+33
View File
@@ -0,0 +1,33 @@
services:
pangolin-sentinel:
build:
context: .
dockerfile: Dockerfile
image: pangolin-sentinel:latest
container_name: pangolin-sentinel
restart: unless-stopped
ports:
- "8888:8888"
volumes:
# Persistent app data (settings, SSH keys, job history, backup index)
- ./data:/data
# Backup storage directory (archives pulled from VPS)
- ./backups:/backups
# For Unraid server, map array paths:
# - /mnt/user/appdata/pangolin-sentinel:/data
# - /mnt/user/backups/pangolin:/backups
environment:
- TZ=UTC
- PORT=8888
- HOST=0.0.0.0
- DATA_DIR=/data
- BACKUP_DIR=/backups
# Optional: set PUID and PGID for Unraid (typically 99 and 100)
# - PUID=99
# - PGID=100
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8888/api/auth/status"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
set -e
# Ensure data and backup directories exist
DATA_PATH="${DATA_DIR:-/data}"
BACKUP_PATH="${BACKUP_DIR:-/backups}"
mkdir -p "$DATA_PATH" "$BACKUP_PATH"
# Set secure permissions on SSH private key if it exists
if [ -f "$DATA_PATH/id_ed25519" ]; then
chmod 600 "$DATA_PATH/id_ed25519" 2>/dev/null || true
fi
# Optional PUID / PGID support (standard for Unraid / Linuxserver containers)
PUID=${PUID:-0}
PGID=${PGID:-0}
if [ "$PUID" -ne 0 ] && [ "$PGID" -ne 0 ]; then
# Create or update group
if ! getent group pangolin >/dev/null 2>&1; then
groupadd -g "$PGID" pangolin 2>/dev/null || true
fi
# Create or update user
if ! getent passwd pangolin >/dev/null 2>&1; then
useradd -u "$PUID" -g "$PGID" -d /app -s /bin/bash pangolin 2>/dev/null || true
fi
# Ensure ownership of persistent data and backup volumes
chown -R "$PUID:$PGID" "$DATA_PATH" "$BACKUP_PATH" 2>/dev/null || true
if [ -f "$DATA_PATH/id_ed25519" ]; then
chmod 600 "$DATA_PATH/id_ed25519" 2>/dev/null || true
fi
exec gosu "$PUID:$PGID" "$@"
else
exec "$@"
fi
+757
View File
@@ -0,0 +1,757 @@
const { createApp, ref, reactive, computed, onMounted, onUnmounted, nextTick } = Vue;
createApp({
setup() {
// Auth State
const authState = reactive({
enabled: true,
setupRequired: false,
isLoggedIn: false,
token: localStorage.getItem("pangolin_token") || "",
password: "",
setupPassword: "",
setupPasswordConfirm: "",
error: ""
});
// Navigation Tab
const currentTab = ref("dashboard");
// Alerts / Toasts
const toasts = ref([]);
const showToast = (message, type = "info") => {
const id = Date.now();
toasts.value.push({ id, message, type });
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id);
}, 4500);
};
// System & VPS Stats State
const systemStats = ref(null);
const updatesData = ref({ total: 0, security_count: 0, packages: [] });
const checkingUpdates = ref(false);
// SSH & VPS Connection State
const publicKeyData = ref({ public_key: "", command_hint: "" });
const connectionTest = reactive({
testing: false,
result: null
});
// Settings State
const settings = ref({
vps_host: "",
vps_port: 22,
vps_user: "root",
vps_auth_method: "key",
vps_ssh_key_path: "/opt/pangolin-manager/data/id_ed25519",
vps_ssh_password: "",
vps_pangolin_dir: "/opt/pangolin",
backup_dir: "/mnt/user/backups/pangolin",
retention_count: 10,
scheduled_backup_enabled: true,
scheduled_backup_cron: "0 3 * * *",
scheduled_backup_scope: "pangolin",
scheduled_update_check_enabled: true,
webhook_url: "",
webhook_type: "discord",
telegram_bot_token: "",
telegram_chat_id: "",
notify_on_backup: true,
notify_on_update: true,
auth_enabled: true
});
const settingsLoading = ref(false);
const testingWebhook = ref(false);
// Pangolin Stack State
const pangolinStatus = ref({
vps_configured: false,
connected: false,
installed: false,
path: "/opt/pangolin",
containers: [],
running_count: 0,
total_count: 0,
api_online: false,
database: { found: false, path: null, size_bytes: 0 }
});
const stackFiles = ref({});
const activeFileKey = ref("docker-compose.yml");
const activeFileContent = ref("");
const savingFile = ref(false);
const containerLogs = ref({ name: "", logs: "", visible: false });
// Backups State (Stored on Unraid)
const backups = ref([]);
const backupsLoading = ref(false);
const backupModal = reactive({
show: false,
scope: "pangolin",
description: ""
});
const inspectModal = reactive({
show: false,
backup: null,
members: [],
loading: false
});
const restoreModal = reactive({
show: false,
backup: null,
confirmText: ""
});
const verifyState = reactive({});
// Password change
const changePasswordForm = reactive({
oldPassword: "",
newPassword: "",
confirmPassword: ""
});
// Jobs & Live Console State
const jobs = ref([]);
const activeJob = ref(null);
const selectedJobId = ref(null);
const autoScroll = ref(true);
const terminalRef = ref(null);
let ws = null;
let statsInterval = null;
// API Helper with Auth
const api = async (endpoint, options = {}) => {
const headers = options.headers || {};
if (authState.token) {
headers["Authorization"] = `Bearer ${authState.token}`;
}
options.headers = headers;
try {
const res = await fetch(endpoint, options);
if (res.status === 401) {
authState.isLoggedIn = false;
throw new Error("Session expired. Please log in.");
}
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || data.error || `HTTP error ${res.status}`);
}
return data;
} catch (err) {
throw err;
}
};
// Check Auth Status
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/status");
const data = await res.json();
authState.enabled = data.auth_enabled;
authState.setupRequired = data.setup_required;
if (!authState.enabled) {
authState.isLoggedIn = true;
initDashboard();
} else if (authState.token && !authState.setupRequired) {
try {
await api("/api/system/stats");
authState.isLoggedIn = true;
initDashboard();
} catch (e) {
authState.isLoggedIn = false;
}
}
} catch (e) {
console.error("Auth check failed:", e);
}
};
// Login
const handleLogin = async () => {
authState.error = "";
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: authState.password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Login failed");
authState.token = data.token;
localStorage.setItem("pangolin_token", data.token);
authState.isLoggedIn = true;
authState.password = "";
showToast("Logged in successfully", "success");
initDashboard();
} catch (err) {
authState.error = err.message;
}
};
// Setup
const handleSetup = async () => {
authState.error = "";
if (authState.setupPassword !== authState.setupPasswordConfirm) {
authState.error = "Passwords do not match.";
return;
}
if (authState.setupPassword.length < 6) {
authState.error = "Password must be at least 6 characters.";
return;
}
try {
const res = await fetch("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: authState.setupPassword })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Setup failed");
authState.token = data.token;
localStorage.setItem("pangolin_token", data.token);
authState.setupRequired = false;
authState.isLoggedIn = true;
showToast("Admin account configured!", "success");
initDashboard();
} catch (err) {
authState.error = err.message;
}
};
// Logout
const handleLogout = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch (e) {}
localStorage.removeItem("pangolin_token");
authState.token = "";
authState.isLoggedIn = false;
showToast("Logged out", "info");
};
// Load Public Key
const fetchPublicKey = async () => {
try {
publicKeyData.value = await api("/api/remote/public-key");
} catch (e) {
console.error("Failed to load public key:", e);
}
};
// Test VPS Connection
const testVpsConnection = async () => {
connectionTest.testing = true;
connectionTest.result = null;
try {
const res = await api("/api/remote/test-connection", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: settings.value })
});
connectionTest.result = res;
if (res.connected) {
showToast(`Connected to ${res.hostname} (${res.os})!`, "success");
await fetchSystemStats();
await fetchPangolinStatus();
} else {
showToast(`Connection failed: ${res.error}`, "error");
}
} catch (e) {
connectionTest.result = { connected: false, error: e.message };
showToast(e.message, "error");
} finally {
connectionTest.testing = false;
}
};
// Load Stats
const fetchSystemStats = async () => {
try {
systemStats.value = await api("/api/system/stats");
} catch (e) {
console.error("Failed to load system stats:", e);
}
};
// Check Updates
const checkUpdates = async () => {
checkingUpdates.value = true;
try {
updatesData.value = await api("/api/system/updates");
showToast(`Found ${updatesData.value.total} package update(s) on VPS`, "info");
} catch (e) {
showToast(`Update check error: ${e.message}`, "error");
} finally {
checkingUpdates.value = false;
}
};
// Upgrade VPS packages
const triggerSystemUpgrade = async (dist = false) => {
if (!confirm(`Are you sure you want to upgrade packages on remote VPS (${settings.value.vps_host})?`)) {
return;
}
try {
const res = await api("/api/system/upgrade", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dist_upgrade: dist })
});
showToast("Remote VPS package upgrade started!", "info");
currentTab.value = "console";
selectedJobId.value = res.job_id;
} catch (e) {
showToast(e.message, "error");
}
};
// Reboot VPS
const triggerReboot = async () => {
if (!confirm(`⚠️ WARNING: Are you sure you want to REBOOT the remote VPS (${settings.value.vps_host})?`)) {
return;
}
try {
await api("/api/system/reboot", { method: "POST" });
showToast("Remote VPS Reboot scheduled in 1 minute.", "warning");
} catch (e) {
showToast(e.message, "error");
}
};
// Pangolin Stack Calls
const fetchPangolinStatus = async () => {
try {
pangolinStatus.value = await api("/api/pangolin/status");
} catch (e) {
console.error("Failed to load Pangolin status:", e);
}
};
const updatePangolinStack = async () => {
if (!confirm(`Update Pangolin stack on remote VPS (${settings.value.vps_host})? This will pull latest Docker images and recreate containers.`)) {
return;
}
try {
const res = await api("/api/pangolin/update", { method: "POST" });
showToast("Remote Pangolin stack update started!", "info");
currentTab.value = "console";
selectedJobId.value = res.job_id;
} catch (e) {
showToast(e.message, "error");
}
};
const handleContainerAction = async (name, action) => {
try {
await api(`/api/pangolin/containers/${name}/action`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action })
});
showToast(`Container ${name} ${action} succeeded on VPS`, "success");
await fetchPangolinStatus();
} catch (e) {
showToast(e.message, "error");
}
};
const viewContainerLogs = async (name) => {
try {
const data = await api(`/api/pangolin/containers/${name}/logs?lines=200`);
containerLogs.value = {
name,
logs: data.logs || "No logs available.",
visible: true
};
} catch (e) {
showToast(e.message, "error");
}
};
const loadStackFiles = async () => {
try {
const files = await api("/api/pangolin/files");
stackFiles.value = files;
const keys = Object.keys(files);
if (keys.length > 0 && (!activeFileKey.value || !files[activeFileKey.value])) {
activeFileKey.value = keys[0];
}
if (activeFileKey.value && files[activeFileKey.value]) {
activeFileContent.value = files[activeFileKey.value];
}
} catch (e) {
console.error("Failed to load stack files:", e);
}
};
const selectStackFile = (key) => {
activeFileKey.value = key;
activeFileContent.value = stackFiles.value[key] || "";
};
const saveStackFile = async () => {
savingFile.value = true;
try {
await api("/api/pangolin/files", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: activeFileKey.value,
content: activeFileContent.value
})
});
stackFiles.value[activeFileKey.value] = activeFileContent.value;
showToast(`Saved ${activeFileKey.value} on remote VPS (snapshot created)`, "success");
} catch (e) {
showToast(e.message, "error");
} finally {
savingFile.value = false;
}
};
// Backups
const fetchBackups = async () => {
backupsLoading.value = true;
try {
backups.value = await api("/api/backups");
} catch (e) {
console.error("Failed to load backups:", e);
} finally {
backupsLoading.value = false;
}
};
const triggerBackup = async () => {
try {
const res = await api("/api/backups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: backupModal.scope,
description: backupModal.description
})
});
backupModal.show = false;
backupModal.description = "";
showToast(`Pulling backup from VPS to Unraid (${backupModal.scope.toUpperCase()})...`, "info");
currentTab.value = "console";
selectedJobId.value = res.job_id;
} catch (e) {
showToast(e.message, "error");
}
};
const inspectBackupArchive = async (b) => {
inspectModal.backup = b;
inspectModal.show = true;
inspectModal.loading = true;
inspectModal.members = [];
try {
const data = await api(`/api/backups/${b.id}/inspect`);
inspectModal.members = data.members;
} catch (e) {
showToast(`Inspect error: ${e.message}`, "error");
} finally {
inspectModal.loading = false;
}
};
const verifyBackupArchive = async (b) => {
verifyState[b.id] = { loading: true };
try {
const res = await api(`/api/backups/${b.id}/verify`, { method: "POST" });
verifyState[b.id] = {
loading: false,
verified: res.verified,
details: res.verified ? "Integrity OK on Unraid storage" : "Verification FAILED"
};
showToast(res.verified ? `Backup ${b.id} integrity verified on Unraid!` : "Integrity check failed!", res.verified ? "success" : "error");
} catch (e) {
verifyState[b.id] = { loading: false, verified: false, details: e.message };
showToast(e.message, "error");
}
};
const promptRestore = (b) => {
restoreModal.backup = b;
restoreModal.confirmText = "";
restoreModal.show = true;
};
const executeRestore = async () => {
if (restoreModal.confirmText !== "RESTORE") {
showToast("Please type RESTORE to confirm.", "warning");
return;
}
const b = restoreModal.backup;
restoreModal.show = false;
try {
const res = await api(`/api/backups/${b.id}/restore`, { method: "POST" });
showToast(`Pushed restore job to remote VPS!`, "warning");
currentTab.value = "console";
selectedJobId.value = res.job_id;
} catch (e) {
showToast(e.message, "error");
}
};
const deleteBackupArchive = async (b) => {
if (!confirm(`Permanently delete backup "${b.filename}" from Unraid?`)) {
return;
}
try {
await api(`/api/backups/${b.id}`, { method: "DELETE" });
showToast("Backup deleted from Unraid", "info");
await fetchBackups();
} catch (e) {
showToast(e.message, "error");
}
};
const handleUploadBackup = async (event) => {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
try {
showToast(`Uploading ${file.name} to Unraid...`, "info");
const headers = {};
if (authState.token) headers["Authorization"] = `Bearer ${authState.token}`;
const res = await fetch("/api/backups/upload", {
method: "POST",
headers,
body: formData
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Upload failed");
showToast("Backup uploaded to Unraid storage!", "success");
await fetchBackups();
} catch (e) {
showToast(e.message, "error");
} finally {
event.target.value = "";
}
};
const downloadBackupUrl = (b) => {
return `/api/backups/${b.id}/download?token=${encodeURIComponent(authState.token)}`;
};
// Settings
const fetchSettings = async () => {
try {
settings.value = await api("/api/settings");
} catch (e) {
console.error("Failed to load settings:", e);
}
};
const saveSettings = async () => {
settingsLoading.value = true;
try {
await api("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: settings.value })
});
showToast("Configuration saved successfully!", "success");
await fetchSystemStats();
await fetchPangolinStatus();
} catch (e) {
showToast(e.message, "error");
} finally {
settingsLoading.value = false;
}
};
const testWebhook = async () => {
testingWebhook.value = true;
try {
const res = await api("/api/notifications/test", { method: "POST" });
if (res.success) {
showToast("Webhook test sent successfully!", "success");
} else {
showToast(`Webhook error: ${res.error || res.text}`, "error");
}
} catch (e) {
showToast(`Webhook test error: ${e.message}`, "error");
} finally {
testingWebhook.value = false;
}
};
// Jobs & WebSocket Console
const fetchJobs = async () => {
try {
jobs.value = await api("/api/jobs");
if (!selectedJobId.value && jobs.value.length > 0) {
selectedJobId.value = jobs.value[0].id;
}
} catch (e) {
console.error("Failed to load jobs:", e);
}
};
const displayedJob = computed(() => {
if (activeJob.value && (!selectedJobId.value || selectedJobId.value === activeJob.value.id)) {
return activeJob.value;
}
return jobs.value.find(j => j.id === selectedJobId.value) || activeJob.value || null;
});
const connectWebSocket = () => {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${proto}//${window.location.host}/ws/jobs`;
try {
ws = new WebSocket(url);
ws.onopen = () => {
setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send("ping");
}, 20000);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "job_started") {
activeJob.value = msg.job;
selectedJobId.value = msg.job.id;
jobs.value.unshift(msg.job);
} else if (msg.type === "log") {
if (activeJob.value && activeJob.value.id === msg.job_id) {
activeJob.value.logs.push(msg.data);
scrollTerminal();
}
const target = jobs.value.find(j => j.id === msg.job_id);
if (target && target !== activeJob.value) {
target.logs.push(msg.data);
}
} else if (msg.type === "job_finished") {
if (activeJob.value && activeJob.value.id === msg.job.id) {
activeJob.value = msg.job;
}
const idx = jobs.value.findIndex(j => j.id === msg.job.id);
if (idx !== -1) jobs.value[idx] = msg.job;
if (msg.job.type === "backup") fetchBackups();
if (msg.job.type === "update_pangolin" || msg.job.type === "restore") fetchPangolinStatus();
if (msg.job.type === "update_system") fetchSystemStats();
}
} catch (e) {
console.error("WS error:", e);
}
};
ws.onclose = () => setTimeout(connectWebSocket, 4000);
} catch (e) {
console.error("WS connection error:", e);
}
};
const scrollTerminal = () => {
if (!autoScroll.value) return;
nextTick(() => {
if (terminalRef.value) {
terminalRef.value.scrollTop = terminalRef.value.scrollHeight;
}
});
};
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
showToast("Copied to clipboard!", "info");
};
// Dashboard init
const initDashboard = async () => {
await fetchSettings();
await fetchPublicKey();
await fetchSystemStats();
await fetchPangolinStatus();
await fetchBackups();
await fetchJobs();
loadStackFiles();
if (settings.value.vps_host) {
checkUpdates();
}
connectWebSocket();
if (statsInterval) clearInterval(statsInterval);
statsInterval = setInterval(() => {
if (authState.isLoggedIn && settings.value.vps_host) {
fetchSystemStats();
}
}, 5000);
};
onMounted(() => checkAuth());
onUnmounted(() => {
if (statsInterval) clearInterval(statsInterval);
if (ws) ws.close();
});
return {
authState,
currentTab,
toasts,
showToast,
systemStats,
updatesData,
checkingUpdates,
publicKeyData,
connectionTest,
settings,
settingsLoading,
testingWebhook,
pangolinStatus,
stackFiles,
activeFileKey,
activeFileContent,
savingFile,
containerLogs,
backups,
backupsLoading,
backupModal,
inspectModal,
restoreModal,
verifyState,
changePasswordForm,
jobs,
activeJob,
selectedJobId,
displayedJob,
autoScroll,
terminalRef,
handleLogin,
handleSetup,
handleLogout,
testVpsConnection,
fetchSystemStats,
checkUpdates,
triggerSystemUpgrade,
triggerReboot,
fetchPangolinStatus,
updatePangolinStack,
handleContainerAction,
viewContainerLogs,
selectStackFile,
saveStackFile,
fetchBackups,
triggerBackup,
inspectBackupArchive,
verifyBackupArchive,
promptRestore,
executeRestore,
deleteBackupArchive,
handleUploadBackup,
downloadBackupUrl,
saveSettings,
testWebhook,
copyToClipboard
};
}
}).mount("#app");
Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.
+81
View File
@@ -0,0 +1,81 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
:root {
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
}
code, pre, .font-mono {
font-family: 'JetBrains Mono', monospace;
}
/* Custom modern scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.6);
}
::-webkit-scrollbar-thumb {
background: rgba(71, 85, 105, 0.6);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.9);
}
/* Glassmorphism cards */
.glass-panel {
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.glass-panel-hover:hover {
border-color: rgba(99, 102, 241, 0.35);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4), 0 0 15px -3px rgba(99, 102, 241, 0.15);
}
/* Glowing pulses */
.pulse-emerald {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
animation: pulse-green 2s infinite;
}
@keyframes pulse-green {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
/* Terminal styling */
.terminal-window {
background: #090d16;
border: 1px solid #1e293b;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
line-height: 1.5;
}
.terminal-line {
word-break: break-all;
white-space: pre-wrap;
}
/* Transition animations */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
+1117
View File
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0"?>
<Container version="2">
<Name>PangolinSentinel</Name>
<Repository>pangolin-sentinel:latest</Repository>
<Registry>https://hub.docker.com/</Registry>
<Network>bridge</Network>
<MyIP/>
<Shell>bash</Shell>
<Privileged>false</Privileged>
<Support/>
<Project>https://github.com/fosrl/pangolin</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>
<ExtraParams/>
<PostArgs/>
<CPUset/>
<DateInstalled/>
<DonateText/>
<DonateLink/>
<Requires/>
<Config Name="WebUI Port" Target="8888" Default="8888" Mode="tcp" Description="Port for the Sentinel Web Dashboard" Type="Port" Display="always" Required="true" Mask="false">8888</Config>
<Config Name="App Data" Target="/data" Default="/mnt/user/appdata/pangolin-sentinel" Mode="rw" Description="Location for settings, SSH keys, and backup catalog" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/pangolin-sentinel</Config>
<Config Name="Backup Storage" Target="/backups" Default="/mnt/user/backups/pangolin" Mode="rw" Description="Location on Unraid array to store backup archives pulled from the VPS" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/backups/pangolin</Config>
<Config Name="PUID" Target="PUID" Default="99" Mode="" Description="Container User ID (Unraid default: 99 for nobody)" Type="Variable" Display="advanced" Required="false" Mask="false">99</Config>
<Config Name="PGID" Target="PGID" Default="100" Mode="" Description="Container Group ID (Unraid default: 100 for users)" Type="Variable" Display="advanced" Required="false" Mask="false">100</Config>
<Config Name="Timezone" Target="TZ" Default="UTC" Mode="" Description="Timezone for scheduler and logs" Type="Variable" Display="advanced" Required="false" Mask="false">UTC</Config>
</Container>
+11
View File
@@ -0,0 +1,11 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
pydantic>=2.8.0
apscheduler>=3.10.0
psutil>=5.9.0
pyyaml>=6.0
aiofiles>=24.0.0
httpx>=0.27.0
python-multipart>=0.0.9
paramiko>=3.4.0
asyncssh>=2.17.0