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
+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)
|
||||
Reference in new issue
Block a user