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("