Refactor golden image handling in backup upload process</message>

<message>Update the _set_golden_from_path function to improve the handling of existing golden image files. Replace the existing unlink logic with a more robust method that safely removes files or broken symlinks using the missing_ok parameter. This change enhances the reliability of the backup upload process by ensuring that stale references are properly cleared before setting a new golden image path.
This commit is contained in:
nearxos
2026-02-24 00:19:40 +02:00
parent df180120aa
commit 808fbf5c7c
136 changed files with 407837 additions and 2 deletions

View File

@@ -0,0 +1,4 @@
"""
API routes for GNSS Guard Server
"""

View File

@@ -0,0 +1,488 @@
#!/usr/bin/env python3
"""
REST API endpoints for GNSS Guard Server
Handles validation data submission and retrieval
"""
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Header, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc
from database import get_db
from models import (
Asset, ValidationHistory, AssetNotificationState,
ValidationSubmission, ValidationBatchSubmission,
ValidationResponse, AssetStatus, AssetResponse, AssetCreate, AssetWithToken,
AssetImport, AssetBatchImport
)
from routes.auth import get_current_user
from services.telegram_service import get_telegram_service
logger = logging.getLogger("gnss_guard.server.api")
router = APIRouter(prefix="/api/v1", tags=["api"])
# =============================================================================
# Asset Token Authentication Dependency
# =============================================================================
async def get_current_asset(
authorization: str = Header(..., description="Bearer token for asset authentication"),
db: Session = Depends(get_db)
) -> Asset:
"""
Dependency to authenticate asset using Bearer token.
Returns the authenticated asset or raises 401.
"""
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Invalid authorization header format")
token = authorization[7:] # Remove "Bearer " prefix
token_hash = Asset.hash_token(token)
asset = db.query(Asset).filter(
Asset.token_hash == token_hash,
Asset.is_active == True
).first()
if not asset:
raise HTTPException(status_code=401, detail="Invalid or inactive token")
return asset
# =============================================================================
# Validation Endpoints (Asset Authentication Required)
# =============================================================================
@router.post("/validation", status_code=201)
async def submit_validation(
data: ValidationSubmission,
asset: Asset = Depends(get_current_asset),
db: Session = Depends(get_db)
) -> dict:
"""
Submit a single validation record from an asset.
Also triggers Telegram notifications if state changed.
"""
try:
validation = ValidationHistory(
asset_id=asset.id,
validation_timestamp=data.validation_timestamp,
validation_timestamp_unix=data.validation_timestamp_unix,
is_valid=data.is_valid,
sources_missing=json.dumps(data.sources_missing),
sources_stale=json.dumps(data.sources_stale),
coordinate_differences=json.dumps(data.coordinate_differences),
source_coordinates=json.dumps(data.source_coordinates),
validation_details=json.dumps(data.validation_details),
)
db.add(validation)
db.commit()
logger.info(f"Validation received from asset '{asset.name}' at {data.validation_timestamp}")
# Process Telegram notification (will only send if state changed)
try:
telegram_service = get_telegram_service()
validation_data = {
"sources_missing": data.sources_missing,
"sources_stale": data.sources_stale,
"validation_details": data.validation_details,
"source_coordinates": data.source_coordinates,
}
telegram_service.process_validation(db, asset, validation_data)
except Exception as e:
logger.warning(f"Telegram notification error for {asset.name}: {e}")
return {
"status": "success",
"message": "Validation record saved",
"id": validation.id
}
except Exception as e:
logger.error(f"Error saving validation from {asset.name}: {e}")
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/validation/batch", status_code=201)
async def submit_validation_batch(
data: ValidationBatchSubmission,
asset: Asset = Depends(get_current_asset),
db: Session = Depends(get_db)
) -> dict:
"""
Submit multiple validation records (for catching up after offline period).
Only sends Telegram notification for the most recent record to avoid spam.
"""
try:
saved_count = 0
skipped_count = 0
latest_record = None
latest_timestamp = 0
for record in data.records:
# Check if this timestamp already exists for this asset
existing = db.query(ValidationHistory).filter(
ValidationHistory.asset_id == asset.id,
ValidationHistory.validation_timestamp_unix == record.validation_timestamp_unix
).first()
if existing:
skipped_count += 1
continue
validation = ValidationHistory(
asset_id=asset.id,
validation_timestamp=record.validation_timestamp,
validation_timestamp_unix=record.validation_timestamp_unix,
is_valid=record.is_valid,
sources_missing=json.dumps(record.sources_missing),
sources_stale=json.dumps(record.sources_stale),
coordinate_differences=json.dumps(record.coordinate_differences),
source_coordinates=json.dumps(record.source_coordinates),
validation_details=json.dumps(record.validation_details),
)
db.add(validation)
saved_count += 1
# Track the most recent record for notification
if record.validation_timestamp_unix > latest_timestamp:
latest_timestamp = record.validation_timestamp_unix
latest_record = record
db.commit()
logger.info(f"Batch validation from '{asset.name}': {saved_count} saved, {skipped_count} skipped")
# Process Telegram notification for the most recent record only
if latest_record:
try:
telegram_service = get_telegram_service()
validation_data = {
"sources_missing": latest_record.sources_missing,
"sources_stale": latest_record.sources_stale,
"validation_details": latest_record.validation_details,
"source_coordinates": latest_record.source_coordinates,
}
telegram_service.process_validation(db, asset, validation_data)
except Exception as e:
logger.warning(f"Telegram notification error for {asset.name}: {e}")
return {
"status": "success",
"saved": saved_count,
"skipped": skipped_count
}
except Exception as e:
logger.error(f"Error saving batch validation from {asset.name}: {e}")
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
# =============================================================================
# Read Endpoints (Session Authentication Required)
# =============================================================================
@router.get("/assets", response_model=List[AssetResponse])
async def list_assets(
user: str = Depends(get_current_user),
db: Session = Depends(get_db)
) -> List[AssetResponse]:
"""
List all registered assets.
Requires user session authentication.
"""
assets = db.query(Asset).filter(Asset.is_active == True).all()
return assets
@router.get("/assets/{asset_name}/status")
async def get_asset_status(
asset_name: str,
user: str = Depends(get_current_user),
db: Session = Depends(get_db)
) -> AssetStatus:
"""
Get current status of an asset (latest validation).
Requires user session authentication.
"""
asset = db.query(Asset).filter(
Asset.name == asset_name,
Asset.is_active == True
).first()
if not asset:
raise HTTPException(status_code=404, detail=f"Asset '{asset_name}' not found")
# Get latest validation
latest = db.query(ValidationHistory).filter(
ValidationHistory.asset_id == asset.id
).order_by(desc(ValidationHistory.validation_timestamp_unix)).first()
# Get online status from notification state (consistent with Telegram alerts)
notification_state = db.query(AssetNotificationState).filter(
AssetNotificationState.asset_id == asset.id
).first()
is_online = notification_state.is_online if notification_state else False
last_seen = notification_state.last_validation_at if notification_state else None
# Fall back to validation timestamp if no notification state
if not last_seen and latest and latest.received_at:
last_seen = latest.received_at
latest_validation = None
if latest:
latest_validation = ValidationResponse(
id=latest.id,
asset_name=asset.name,
validation_timestamp=latest.validation_timestamp,
validation_timestamp_unix=latest.validation_timestamp_unix,
is_valid=latest.is_valid,
sources_missing=json.loads(latest.sources_missing or "[]"),
sources_stale=json.loads(latest.sources_stale or "[]"),
coordinate_differences=json.loads(latest.coordinate_differences or "{}"),
source_coordinates=json.loads(latest.source_coordinates or "{}"),
validation_details=json.loads(latest.validation_details or "{}"),
received_at=latest.received_at
)
return AssetStatus(
asset_name=asset.name,
is_online=is_online,
last_seen=last_seen,
latest_validation=latest_validation
)
@router.get("/assets/{asset_name}/history")
async def get_asset_history(
asset_name: str,
hours: int = Query(default=72, ge=1, le=168, description="Hours of history (max 168 = 7 days)"),
user: str = Depends(get_current_user),
db: Session = Depends(get_db)
) -> List[ValidationResponse]:
"""
Get validation history for an asset (default: 72 hours).
Requires user session authentication.
"""
asset = db.query(Asset).filter(
Asset.name == asset_name,
Asset.is_active == True
).first()
if not asset:
raise HTTPException(status_code=404, detail=f"Asset '{asset_name}' not found")
# Calculate cutoff timestamp
cutoff = datetime.utcnow() - timedelta(hours=hours)
cutoff_unix = cutoff.timestamp()
# Get validation history
validations = db.query(ValidationHistory).filter(
ValidationHistory.asset_id == asset.id,
ValidationHistory.validation_timestamp_unix >= cutoff_unix
).order_by(desc(ValidationHistory.validation_timestamp_unix)).all()
return [
ValidationResponse(
id=v.id,
asset_name=asset.name,
validation_timestamp=v.validation_timestamp,
validation_timestamp_unix=v.validation_timestamp_unix,
is_valid=v.is_valid,
sources_missing=json.loads(v.sources_missing or "[]"),
sources_stale=json.loads(v.sources_stale or "[]"),
coordinate_differences=json.loads(v.coordinate_differences or "{}"),
source_coordinates=json.loads(v.source_coordinates or "{}"),
validation_details=json.loads(v.validation_details or "{}"),
received_at=v.received_at
)
for v in validations
]
# =============================================================================
# Admin Endpoints (Session Authentication Required)
# =============================================================================
@router.post("/admin/assets", response_model=AssetWithToken, status_code=201)
async def create_asset(
data: AssetCreate,
user: str = Depends(get_current_user),
db: Session = Depends(get_db)
) -> AssetWithToken:
"""
Create a new asset and return its token.
Requires user session authentication.
"""
# Check if asset already exists
existing = db.query(Asset).filter(Asset.name == data.name).first()
if existing:
raise HTTPException(status_code=400, detail=f"Asset '{data.name}' already exists")
# Generate token
token = Asset.generate_token()
token_hash = Asset.hash_token(token)
asset = Asset(
name=data.name,
token_hash=token_hash,
description=data.description,
telegram_chat_id=data.telegram_chat_id,
telegram_enabled=data.telegram_enabled
)
db.add(asset)
db.commit()
db.refresh(asset)
logger.info(f"Created new asset: {data.name}")
# Return asset with the unhashed token (only shown once!)
return AssetWithToken(
id=asset.id,
name=asset.name,
is_active=asset.is_active,
created_at=asset.created_at,
description=asset.description,
telegram_chat_id=asset.telegram_chat_id,
telegram_enabled=asset.telegram_enabled,
token=token
)
@router.delete("/admin/assets/{asset_name}")
async def deactivate_asset(
asset_name: str,
user: str = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""
Deactivate an asset (soft delete).
Requires user session authentication.
"""
asset = db.query(Asset).filter(Asset.name == asset_name).first()
if not asset:
raise HTTPException(status_code=404, detail=f"Asset '{asset_name}' not found")
asset.is_active = False
db.commit()
logger.info(f"Deactivated asset: {asset_name}")
return {"status": "success", "message": f"Asset '{asset_name}' deactivated"}
@router.post("/admin/assets/import", response_model=AssetResponse, status_code=201)
async def import_asset(
data: AssetImport,
user: str = Depends(get_current_user),
db: Session = Depends(get_db)
) -> AssetResponse:
"""
Import an asset with a specific token.
If asset exists, updates its token. If not, creates it.
Requires user session authentication.
"""
# Hash the provided token
token_hash = Asset.hash_token(data.token)
# Check if asset already exists
existing = db.query(Asset).filter(Asset.name == data.name).first()
if existing:
# Update existing asset's token
existing.token_hash = token_hash
existing.is_active = True
if data.description:
existing.description = data.description
if data.telegram_chat_id is not None:
existing.telegram_chat_id = data.telegram_chat_id
existing.telegram_enabled = data.telegram_enabled
db.commit()
db.refresh(existing)
logger.info(f"Updated token for existing asset: {data.name}")
return existing
else:
# Create new asset with provided token
asset = Asset(
name=data.name,
token_hash=token_hash,
description=data.description,
telegram_chat_id=data.telegram_chat_id,
telegram_enabled=data.telegram_enabled
)
db.add(asset)
db.commit()
db.refresh(asset)
logger.info(f"Imported new asset: {data.name}")
return asset
@router.post("/admin/assets/import/batch")
async def import_assets_batch(
data: AssetBatchImport,
user: str = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""
Batch import assets with specific tokens.
Creates new assets or updates existing ones.
Requires user session authentication.
"""
created = 0
updated = 0
errors = []
for asset_data in data.assets:
try:
token_hash = Asset.hash_token(asset_data.token)
existing = db.query(Asset).filter(Asset.name == asset_data.name).first()
if existing:
existing.token_hash = token_hash
existing.is_active = True
if asset_data.description:
existing.description = asset_data.description
if asset_data.telegram_chat_id is not None:
existing.telegram_chat_id = asset_data.telegram_chat_id
existing.telegram_enabled = asset_data.telegram_enabled
updated += 1
logger.info(f"Updated token for asset: {asset_data.name}")
else:
asset = Asset(
name=asset_data.name,
token_hash=token_hash,
description=asset_data.description,
telegram_chat_id=asset_data.telegram_chat_id,
telegram_enabled=asset_data.telegram_enabled
)
db.add(asset)
created += 1
logger.info(f"Created asset: {asset_data.name}")
except Exception as e:
errors.append({"name": asset_data.name, "error": str(e)})
logger.error(f"Failed to import asset {asset_data.name}: {e}")
db.commit()
return {
"status": "success",
"created": created,
"updated": updated,
"errors": errors
}

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""
Authentication routes for GNSS Guard Server
Handles user session authentication for the web UI
"""
import logging
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from slowapi import Limiter
from slowapi.util import get_remote_address
from config import get_config
logger = logging.getLogger("gnss_guard.server.auth")
router = APIRouter(tags=["auth"])
# Rate limiter instance (uses app.state.limiter set in main.py)
limiter = Limiter(key_func=get_remote_address)
# Simple in-memory session storage (for single-user scenario)
# In production with multiple servers, use Redis or database
_sessions: dict = {}
class LoginRequest(BaseModel):
username: str
password: str
def create_session(username: str) -> str:
"""Create a new session and return session ID"""
import secrets
session_id = secrets.token_urlsafe(32)
config = get_config()
_sessions[session_id] = {
"username": username,
"created_at": datetime.utcnow(),
"expires_at": datetime.utcnow() + timedelta(minutes=config.session_expire_minutes)
}
return session_id
def validate_session(session_id: str) -> Optional[str]:
"""Validate session and return username if valid"""
if not session_id or session_id not in _sessions:
return None
session = _sessions[session_id]
if datetime.utcnow() > session["expires_at"]:
del _sessions[session_id]
return None
return session["username"]
def get_current_user(request: Request) -> str:
"""
Dependency to get current authenticated user.
Raises 401 if not authenticated.
"""
session_id = request.cookies.get("session_id")
username = validate_session(session_id)
if not username:
raise HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"}
)
return username
def get_optional_user(request: Request) -> Optional[str]:
"""
Dependency to get current user if authenticated, None otherwise.
"""
session_id = request.cookies.get("session_id")
return validate_session(session_id)
@router.post("/login")
@limiter.limit("5/minute") # Rate limit: 5 login attempts per minute per IP
async def login(request: Request, data: LoginRequest, response: Response):
"""
Login endpoint - validates credentials and sets session cookie.
Rate limited to prevent brute force attacks.
"""
config = get_config()
# Verify credentials against hardcoded user
if data.username != config.web_username or data.password != config.web_password:
logger.warning(f"Failed login attempt for user: {data.username} from IP: {request.client.host}")
raise HTTPException(status_code=401, detail="Invalid credentials")
# Create session
session_id = create_session(data.username)
# Set session cookie
# secure=True ensures cookie only sent over HTTPS
response.set_cookie(
key="session_id",
value=session_id,
httponly=True,
secure=True, # Only send over HTTPS
samesite="lax",
max_age=config.session_expire_minutes * 60
)
logger.info(f"User logged in: {data.username}")
return {"message": "Login successful", "username": data.username}
@router.post("/logout")
async def logout(request: Request, response: Response):
"""
Logout endpoint - clears session.
"""
session_id = request.cookies.get("session_id")
if session_id and session_id in _sessions:
del _sessions[session_id]
response.delete_cookie("session_id")
return {"message": "Logged out successfully"}
@router.get("/auth/check")
async def check_auth(request: Request):
"""
Check if current session is authenticated.
"""
session_id = request.cookies.get("session_id")
username = validate_session(session_id)
if username:
return {"authenticated": True, "username": username}
else:
return {"authenticated": False}