Initial commit: Pangolin Sentinel Docker & Unraid Hub

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

No files matched your search

+422
View File
@@ -0,0 +1,422 @@
import asyncio
import os
import shutil
import sys
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Dict, Any, Optional
# Ensure project root is in sys.path so backend modules can be imported directly
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from fastapi import (
FastAPI, HTTPException, Depends, Request, Response,
WebSocket, WebSocketDisconnect, UploadFile, File, Form, status
)
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from backend.config import (
load_settings, save_settings, get_setting, update_setting,
BASE_DIR, DATA_DIR, DEFAULT_KEY_FILE, DEFAULT_PUB_KEY_FILE
)
from backend.auth import (
require_auth, hash_password, verify_password,
create_session_token, is_setup_required
)
from backend.system import (
get_system_stats, check_upgradable_packages,
trigger_system_upgrade, trigger_system_reboot
)
from backend.pangolin import (
get_pangolin_stack_status,
trigger_pangolin_update, container_action,
get_container_logs, get_stack_config_files,
save_stack_config_file
)
from backend.backup import (
list_backups, run_backup_task, inspect_backup,
verify_backup, restore_backup, delete_backup,
get_backup_dir, load_backup_index, save_backup_index,
compute_sha256
)
from backend.remote import (
get_public_key, ensure_ssh_key, test_vps_connection
)
from backend.runner import runner
from backend.scheduler import start_scheduler, shutdown_scheduler, reload_scheduler
from backend.notifier import test_webhook, send_notification
@asynccontextmanager
async def lifespan(app: FastAPI):
print("[APP] Initializing Pangolin Sentinel (Unraid -> VPS)...")
ensure_ssh_key()
start_scheduler()
yield
print("[APP] Shutting down Pangolin Sentinel...")
shutdown_scheduler()
app = FastAPI(
title="Pangolin Sentinel — Unraid to VPS Hub",
description="Full web app on Unraid for updating and backing up remote Pangolin VPS",
version="2.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
FRONTEND_DIR = BASE_DIR / "frontend"
ASSETS_DIR = FRONTEND_DIR / "assets"
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/assets", StaticFiles(directory=str(ASSETS_DIR)), name="assets")
# ----------------- Auth Endpoints -----------------
class SetupRequest(BaseModel):
password: str
class LoginRequest(BaseModel):
password: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
@app.get("/api/auth/status")
async def get_auth_status():
settings = load_settings()
return {
"auth_enabled": settings.get("auth_enabled", True),
"setup_required": is_setup_required()
}
@app.post("/api/auth/setup")
async def initial_setup(req: SetupRequest, response: Response):
if not is_setup_required():
raise HTTPException(status_code=400, detail="Admin password is already configured.")
if len(req.password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters.")
pwd_hash, salt = hash_password(req.password)
update_setting("admin_password_hash", pwd_hash)
update_setting("admin_password_salt", salt)
token = create_session_token()
response.set_cookie("session_token", token, httponly=True, max_age=86400 * 7, samesite="lax")
return {"success": True, "token": token}
@app.post("/api/auth/login")
async def login(req: LoginRequest, response: Response):
settings = load_settings()
if not settings.get("auth_enabled", True):
token = create_session_token()
return {"success": True, "token": token}
pwd_hash = settings.get("admin_password_hash", "")
salt = settings.get("admin_password_salt", "")
if not pwd_hash:
raise HTTPException(status_code=400, detail="Setup required. Please set admin password first.")
if not verify_password(req.password, pwd_hash, salt):
raise HTTPException(status_code=401, detail="Invalid password.")
token = create_session_token()
response.set_cookie("session_token", token, httponly=True, max_age=86400 * 7, samesite="lax")
return {"success": True, "token": token}
@app.post("/api/auth/logout")
async def logout(response: Response):
response.delete_cookie("session_token")
return {"success": True}
@app.post("/api/auth/change-password", dependencies=[Depends(require_auth)])
async def change_password(req: ChangePasswordRequest):
settings = load_settings()
pwd_hash = settings.get("admin_password_hash", "")
salt = settings.get("admin_password_salt", "")
if not verify_password(req.old_password, pwd_hash, salt):
raise HTTPException(status_code=400, detail="Incorrect current password.")
if len(req.new_password) < 6:
raise HTTPException(status_code=400, detail="New password must be at least 6 characters.")
new_hash, new_salt = hash_password(req.new_password)
update_setting("admin_password_hash", new_hash)
update_setting("admin_password_salt", new_salt)
return {"success": True, "message": "Password updated successfully."}
# ----------------- Remote VPS Connection Endpoints -----------------
@app.get("/api/remote/public-key", dependencies=[Depends(require_auth)])
async def api_get_public_key():
pub = ensure_ssh_key()
return {
"public_key": pub,
"key_type": "ed25519",
"command_hint": f'echo "{pub}" >> ~/.ssh/authorized_keys'
}
class TestConnectionRequest(BaseModel):
settings: Optional[Dict[str, Any]] = None
@app.post("/api/remote/test-connection", dependencies=[Depends(require_auth)])
async def api_test_vps_connection(req: TestConnectionRequest):
res = await test_vps_connection(req.settings)
return res
# ----------------- System Endpoints (Remote VPS) -----------------
@app.get("/api/system/stats", dependencies=[Depends(require_auth)])
async def api_system_stats():
return await get_system_stats()
@app.get("/api/system/updates", dependencies=[Depends(require_auth)])
async def api_check_updates():
return await check_upgradable_packages()
class UpgradeRequest(BaseModel):
dist_upgrade: bool = False
@app.post("/api/system/upgrade", dependencies=[Depends(require_auth)])
async def api_trigger_upgrade(req: UpgradeRequest):
res = await trigger_system_upgrade(req.dist_upgrade)
if not res.get("success"):
raise HTTPException(status_code=409, detail=res.get("error"))
return res
@app.post("/api/system/reboot", dependencies=[Depends(require_auth)])
async def api_trigger_reboot():
res = await trigger_system_reboot()
return res
# ----------------- Pangolin Stack Endpoints (Remote VPS) -----------------
@app.get("/api/pangolin/status", dependencies=[Depends(require_auth)])
async def api_pangolin_status():
return await get_pangolin_stack_status()
@app.post("/api/pangolin/update", dependencies=[Depends(require_auth)])
async def api_trigger_pangolin_update():
res = await trigger_pangolin_update()
if not res.get("success"):
raise HTTPException(status_code=400, detail=res.get("error"))
return res
class ContainerActionRequest(BaseModel):
action: str
@app.post("/api/pangolin/containers/{name}/action", dependencies=[Depends(require_auth)])
async def api_container_action(name: str, req: ContainerActionRequest):
res = await container_action(name, req.action)
if not res.get("success"):
raise HTTPException(status_code=400, detail=res.get("error"))
return res
@app.get("/api/pangolin/containers/{name}/logs", dependencies=[Depends(require_auth)])
async def api_container_logs(name: str, lines: int = 150):
return await get_container_logs(name, lines)
@app.get("/api/pangolin/files", dependencies=[Depends(require_auth)])
async def api_get_stack_files():
return await get_stack_config_files()
class SaveFileRequest(BaseModel):
filename: str
content: str
@app.post("/api/pangolin/files", dependencies=[Depends(require_auth)])
async def api_save_stack_file(req: SaveFileRequest):
res = await save_stack_config_file(req.filename, req.content)
if not res.get("success"):
raise HTTPException(status_code=400, detail=res.get("error"))
return res
# ----------------- Backups Endpoints (Stored on Unraid) -----------------
@app.get("/api/backups", dependencies=[Depends(require_auth)])
async def api_list_backups():
return list_backups()
class CreateBackupRequest(BaseModel):
scope: str = "pangolin"
description: str = ""
@app.post("/api/backups", dependencies=[Depends(require_auth)])
async def api_create_backup(req: CreateBackupRequest):
res = await run_backup_task(
scope=req.scope,
description=req.description or "Manual backup pulled from VPS to Unraid",
auto_created=False
)
if not res.get("success"):
raise HTTPException(status_code=409, detail=res.get("error"))
return res
@app.get("/api/backups/{backup_id}/inspect", dependencies=[Depends(require_auth)])
async def api_inspect_backup(backup_id: str):
res = inspect_backup(backup_id)
if not res.get("success"):
raise HTTPException(status_code=404, detail=res.get("error"))
return res
@app.post("/api/backups/{backup_id}/verify", dependencies=[Depends(require_auth)])
async def api_verify_backup(backup_id: str):
res = verify_backup(backup_id)
if not res.get("success"):
raise HTTPException(status_code=404, detail=res.get("error"))
return res
@app.post("/api/backups/{backup_id}/restore", dependencies=[Depends(require_auth)])
async def api_restore_backup(backup_id: str):
res = await restore_backup(backup_id)
if not res.get("success"):
raise HTTPException(status_code=409, detail=res.get("error"))
return res
@app.delete("/api/backups/{backup_id}", dependencies=[Depends(require_auth)])
async def api_delete_backup(backup_id: str):
res = delete_backup(backup_id)
if not res.get("success"):
raise HTTPException(status_code=404, detail=res.get("error"))
return res
@app.get("/api/backups/{backup_id}/download", dependencies=[Depends(require_auth)])
async def api_download_backup(backup_id: str):
index = load_backup_index()
target = next((b for b in index if b["id"] == backup_id), None)
if not target:
raise HTTPException(status_code=404, detail="Backup not found")
filepath = Path(target["path"])
if not filepath.exists():
raise HTTPException(status_code=404, detail="File does not exist on disk")
return FileResponse(
path=str(filepath),
filename=target["filename"],
media_type="application/gzip"
)
@app.post("/api/backups/upload", dependencies=[Depends(require_auth)])
async def api_upload_backup(file: UploadFile = File(...)):
if not file.filename.endswith((".tar.gz", ".tgz")):
raise HTTPException(status_code=400, detail="Only .tar.gz archives are supported.")
bdir = get_backup_dir()
safe_filename = Path(file.filename).name
dest_path = bdir / safe_filename
if dest_path.exists():
dest_path = bdir / f"{dest_path.stem}_{int(time.time())}{dest_path.suffix}"
with open(dest_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
size = dest_path.stat().st_size
sha256 = compute_sha256(dest_path)
index = load_backup_index()
backup_id = f"bk_upload_{int(time.time())}"
entry = {
"id": backup_id,
"filename": dest_path.name,
"path": str(dest_path),
"scope": "uploaded",
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"size_bytes": size,
"formatted_size": f"{size / (1024*1024):.2f} MB",
"sha256": sha256,
"files_count": 0,
"description": f"Uploaded to Unraid: {file.filename}",
"auto_created": False,
"verified": True
}
index.insert(0, entry)
save_backup_index(index)
return {"success": True, "backup": entry}
# ----------------- Settings Endpoints -----------------
@app.get("/api/settings", dependencies=[Depends(require_auth)])
async def api_get_settings():
s = load_settings().copy()
s.pop("admin_password_hash", None)
s.pop("admin_password_salt", None)
s.pop("jwt_secret", None)
return s
class UpdateSettingsRequest(BaseModel):
settings: Dict[str, Any]
@app.post("/api/settings", dependencies=[Depends(require_auth)])
async def api_update_settings(req: UpdateSettingsRequest):
allowed_keys = [
"auth_enabled", "vps_host", "vps_port", "vps_user", "vps_auth_method",
"vps_ssh_key_path", "vps_ssh_password", "vps_pangolin_dir",
"backup_dir", "retention_count",
"scheduled_backup_enabled", "scheduled_backup_cron", "scheduled_backup_scope",
"scheduled_update_check_enabled", "webhook_url", "webhook_type",
"telegram_bot_token", "telegram_chat_id", "notify_on_backup", "notify_on_update"
]
cur = load_settings()
for k, v in req.settings.items():
if k in allowed_keys:
cur[k] = v
save_settings(cur)
reload_scheduler()
return {"success": True, "settings": cur}
@app.post("/api/notifications/test", dependencies=[Depends(require_auth)])
async def api_test_notification():
return await test_webhook()
# ----------------- Jobs & WebSocket -----------------
@app.get("/api/jobs", dependencies=[Depends(require_auth)])
async def api_list_jobs(limit: int = 25):
return runner.list_jobs(limit)
@app.get("/api/jobs/{job_id}", dependencies=[Depends(require_auth)])
async def api_get_job(job_id: str):
job = runner.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@app.websocket("/ws/jobs")
async def websocket_jobs(websocket: WebSocket):
await websocket.accept()
await runner.register_global(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
runner.unregister_global(websocket)
except Exception:
runner.unregister_global(websocket)
# ----------------- SPA Frontend Route -----------------
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
index_path = FRONTEND_DIR / "index.html"
if not index_path.exists():
return HTMLResponse("<h1>Pangolin Sentinel is initializing...</h1>")
return FileResponse(str(index_path))
if __name__ == "__main__":
import uvicorn
port = int(os.environ.get("PORT", get_setting("app_port", 8888)))
host = os.environ.get("HOST", get_setting("app_host", "0.0.0.0"))
uvicorn.run("backend.main:app", host=host, port=port, reload=False)