#!/usr/bin/env python3 """ FastAPI main application for GNSS Guard Server Centralized monitoring server for multiple GNSS Guard assets """ import asyncio import logging import json import random from contextlib import asynccontextmanager from datetime import datetime, timedelta from pathlib import Path from typing import Optional from fastapi import FastAPI, Request, Depends, HTTPException from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.orm import Session from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded from config import get_config from database import init_db, get_db, get_session_factory from routes import api, auth from routes.auth import get_optional_user, get_current_user from services.asset_service import AssetService from services.telegram_service import get_telegram_service from models import Asset, AssetNotificationState # Initialize rate limiter limiter = Limiter(key_func=get_remote_address) # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger("gnss_guard.server") # Create FastAPI app app = FastAPI( title="GNSS Guard Server", description="Centralized monitoring server for GNSS Guard assets", version="1.0.0" ) # Setup rate limiting app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # Add CORS middleware - restricted to same-origin only # Since the dashboard is served from the same domain, we only need # to allow requests from the same origin. This prevents CSRF attacks. config = get_config() allowed_origins = [] if config.server_domain: allowed_origins = [ f"https://{config.server_domain}", f"http://{config.server_domain}", # For initial setup before SSL ] app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, allow_credentials=True, allow_methods=["GET", "POST", "DELETE"], allow_headers=["Content-Type", "Authorization", "Cookie"], ) # Setup static files and templates static_path = Path(__file__).parent / "static" templates_path = Path(__file__).parent / "templates" if static_path.exists(): app.mount("/static", StaticFiles(directory=str(static_path)), name="static") templates = Jinja2Templates(directory=str(templates_path)) if templates_path.exists() else None # Include routers app.include_router(api.router) app.include_router(auth.router) # ============================================================================= # Health Check Endpoint (public, no auth required) # ============================================================================= @app.get("/health") async def health_check(): """Health check endpoint - always accessible""" return {"status": "ok", "timestamp": datetime.utcnow().isoformat()} async def check_offline_assets(): """Background task to check for assets that have gone offline""" config = get_config() telegram_service = get_telegram_service() if not telegram_service.enabled: return threshold = datetime.utcnow() - timedelta(seconds=config.asset_offline_seconds) SessionLocal = get_session_factory() db = SessionLocal() try: # Find assets that are marked online but haven't reported recently states = db.query(AssetNotificationState).join(Asset).filter( AssetNotificationState.is_online == True, AssetNotificationState.last_validation_at != None, AssetNotificationState.last_validation_at < threshold, Asset.is_active == True, Asset.telegram_enabled == True ).all() for state in states: chat_id = state.asset.telegram_chat_id or telegram_service.default_chat_id if chat_id: logger.info(f"Asset '{state.asset.name}' detected as offline (last seen: {state.last_validation_at})") telegram_service.send_asset_offline_alert( chat_id=chat_id, asset_name=state.asset.name, last_seen=state.last_validation_at, offline_threshold_seconds=config.asset_offline_seconds ) state.is_online = False if states: db.commit() except Exception as e: logger.error(f"Error checking offline assets: {e}") db.rollback() finally: db.close() async def offline_checker_loop(): """Background loop that periodically checks for offline assets""" while True: await asyncio.sleep(30) # Check every 30 seconds try: await check_offline_assets() except Exception as e: logger.error(f"Error in offline checker loop: {e}") @app.on_event("startup") async def startup_event(): """Initialize database and background tasks on startup""" logger.info("Starting GNSS Guard Server...") init_db() logger.info("Database initialized") # Start background task for offline detection asyncio.create_task(offline_checker_loop()) logger.info("Offline asset checker started") # ============================================================================= # Web UI Routes # ============================================================================= @app.get("/", response_class=HTMLResponse) async def index(request: Request, user: Optional[str] = Depends(get_optional_user)): """Main dashboard page""" if not user: return RedirectResponse(url="/login", status_code=302) if not templates: return HTMLResponse("
Templates not configured
") return templates.TemplateResponse("dashboard.html", { "request": request, "username": user, "cache_buster": random.randint(100000, 999999) }) @app.get("/login", response_class=HTMLResponse) async def login_page(request: Request, user: Optional[str] = Depends(get_optional_user)): """Login page""" if user: return RedirectResponse(url="/", status_code=302) if not templates: return HTMLResponse("""