Initial commit: Pangolin Sentinel Docker & Unraid Hub
This commit is contained in:
1 parent
178b40ba37
commit
b4095b82ea
24 files changed
+4355
No files matched your search
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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())
|
||||
}
|
||||
)
|
||||
@@ -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}"}
|
||||
@@ -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!"
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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
@@ -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
|
||||
@@ -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.
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
@@ -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
|
||||
Reference in new issue
Block a user