Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ PORT=9000

### Security Settings
JWT_SECRET_KEY="random string here" # change this to a secure random string
JWT_ACCESS_TOKEN_EXPIRES=86400 # in seconds
JWT_ACCESS_TOKEN_EXPIRES=86400 # in seconds

# API Key Authentication (for external integrations)
# Change this to a secure random string for API access
API_KEY="your-secure-api-key-here"
48 changes: 48 additions & 0 deletions backend/alembic/versions/health_sync_fields_add_health_and_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Add health check and sync fields to nodes table

This migration adds new fields to support health monitoring and synchronization:
- is_healthy: boolean flag for node health
- last_health_check: timestamp of last health check
- response_time: response time in seconds
- consecutive_failures: count of consecutive failures
- last_sync_time: timestamp of last sync
- sync_status: current sync status (synced, pending, failed, never_synced)

Revision ID: health_sync_fields
Revises: 494ff940dc52
Create Date: 2025-11-12
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime


# revision identifiers, used by Alembic.
revision = 'health_sync_fields'
down_revision = '494ff940dc52'
branch_labels = None
depends_on = None


def upgrade() -> None:
# Add health check fields
op.add_column('nodes', sa.Column('is_healthy', sa.Boolean(), nullable=False, server_default='1'))
op.add_column('nodes', sa.Column('last_health_check', sa.DateTime(), nullable=True))
op.add_column('nodes', sa.Column('response_time', sa.Float(), nullable=True))
op.add_column('nodes', sa.Column('consecutive_failures', sa.Integer(), nullable=False, server_default='0'))

# Add sync fields
op.add_column('nodes', sa.Column('last_sync_time', sa.DateTime(), nullable=True))
op.add_column('nodes', sa.Column('sync_status', sa.String(), nullable=False, server_default='synced'))


def downgrade() -> None:
# Remove added columns in reverse order
op.drop_column('nodes', 'sync_status')
op.drop_column('nodes', 'last_sync_time')
op.drop_column('nodes', 'consecutive_failures')
op.drop_column('nodes', 'response_time')
op.drop_column('nodes', 'last_health_check')
op.drop_column('nodes', 'is_healthy')


37 changes: 37 additions & 0 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from backend.config import config
from backend.routers import all_routers
from backend.version import __version__
from backend.node.scheduler import scheduler as node_scheduler
from backend.logger import logger


api = FastAPI(
Expand Down Expand Up @@ -53,6 +55,23 @@ def start_scheduler():
@api.on_event("startup")
async def startup_event():
start_scheduler()

# Start node health check and sync scheduler
try:
node_scheduler.start()
logger.info("Node health check and sync scheduler started")
except Exception as e:
logger.error(f"Failed to start node scheduler: {e}")


@api.on_event("shutdown")
async def shutdown_event():
"""Cleanup on shutdown."""
try:
node_scheduler.stop()
logger.info("Node scheduler stopped")
except Exception as e:
logger.error(f"Error stopping node scheduler: {e}")


@api.get(f"/{config.URLPATH}")
Expand All @@ -63,3 +82,21 @@ async def serve_react():

for router in all_routers:
api.include_router(prefix="/api", router=router)


# Catch-all route for SPA routing - must be AFTER all API routes
@api.get("/{full_path:path}")
async def serve_react_app(full_path: str):
"""Serve React app for all non-API routes to support client-side routing."""
# Don't serve index.html for API routes or assets
if full_path.startswith("api/") or full_path.startswith("assets/"):
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not found")

# Serve index.html for all other routes
index_path = os.path.join(frontend_build_path, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
else:
from fastapi import HTTPException
raise HTTPException(status_code=500, detail="Frontend not built")
99 changes: 97 additions & 2 deletions backend/auth/auth.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer, APIKeyHeader
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from typing import Optional, Union
from backend.db.engine import get_db
from backend.config import config
from backend.db import crud
Expand All @@ -14,6 +15,9 @@

router = APIRouter(tags=["Login"])

# API Key security scheme for Swagger UI
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)

def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)

Expand Down Expand Up @@ -59,7 +63,7 @@ async def login(
return {"access_token": access_token, "token_type": "bearer"}


oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{config.URLPATH}/login")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{config.URLPATH}/login", auto_error=False)


def get_current_user(token: str = Depends(oauth2_scheme)):
Expand All @@ -78,3 +82,94 @@ def get_current_user(token: str = Depends(oauth2_scheme)):
except JWTError:
raise credentials_exception
return {"username": username, "type": user_type}


# ==================== NEW API KEY AUTHENTICATION ====================

def verify_api_key(api_key: Optional[str] = Depends(api_key_header)) -> dict:
"""
Verify API key from X-API-Key header.
This is for external integrations that need to access the API.

Usage:
Add header: X-API-Key: your-api-key-here

Returns:
dict with authentication info if valid

Raises:
HTTPException if API key is invalid or missing
"""
if not config.API_KEY:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="API Key authentication is not configured on this server",
)

if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing X-API-Key header",
headers={"WWW-Authenticate": "ApiKey"},
)

if api_key != config.API_KEY:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API Key",
headers={"WWW-Authenticate": "ApiKey"},
)

return {"type": "api_key", "authenticated": True}


def verify_jwt_or_api_key(
token: Optional[str] = Depends(oauth2_scheme),
api_key: Optional[str] = Depends(api_key_header)
) -> dict:
"""
Accept either JWT Bearer token OR API Key authentication.
This allows both frontend (JWT) and external integrations (API Key) to access endpoints.

Priority:
1. Try API Key first if present
2. Fall back to JWT Bearer token
3. Raise error if neither is valid

Returns:
dict with authentication info
"""
# Try API Key first if provided
if api_key:
if not config.API_KEY:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="API Key authentication is not configured",
)
if api_key == config.API_KEY:
return {"type": "api_key", "authenticated": True}
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API Key",
headers={"WWW-Authenticate": "ApiKey"},
)

# Fall back to JWT token
if token:
try:
payload = jwt.decode(token, config.JWT_SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
user_type: str = payload.get("type")
if username:
return {"username": username, "type": user_type, "authenticated": True}
except JWTError:
pass

# Neither authentication method worked
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials. Provide either valid JWT Bearer token or X-API-Key header",
headers={"WWW-Authenticate": "Bearer, ApiKey"},
)

1 change: 1 addition & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class Setting(BaseSettings):
SSL_CERTFILE: Optional[str] = None
JWT_SECRET_KEY: str
JWT_ACCESS_TOKEN_EXPIRES: int = 86400 # in seconds
API_KEY: Optional[str] = None # Optional API key for external integrations

class Config:
env_file = os.path.join(os.path.dirname(__file__), "..", ".env")
Expand Down
98 changes: 98 additions & 0 deletions backend/db/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ def get_all_nodes(db: Session):
return nodes


def get_node_by_id(db: Session, node_id: int):
return db.query(Node).filter(Node.id == node_id).first()


def get_node_by_address(db: Session, address: str):
return db.query(Node).filter(Node.address == address).first()

Expand Down Expand Up @@ -168,3 +172,97 @@ def update_settings(db: Session, request: SettingsUpdate):
db.commit()
db.refresh(settings)
return settings


# Node health and sync management
def update_node_health(
db: Session,
node_id: int,
is_healthy: bool,
response_time: float = None,
consecutive_failures: int = 0,
):
"""Update node health status."""
from datetime import datetime

node = db.query(Node).filter(Node.id == node_id).first()
if not node:
raise HTTPException(status_code=404, detail="Node not found")

node.is_healthy = is_healthy
node.last_health_check = datetime.now()
node.response_time = response_time
node.consecutive_failures = consecutive_failures

# Auto-update status based on health
if consecutive_failures >= 3:
node.status = False

db.commit()
db.refresh(node)
return node


def update_node_sync_status(
db: Session, node_id: int, sync_status: str
):
"""Update node sync status."""
from datetime import datetime

node = db.query(Node).filter(Node.id == node_id).first()
if not node:
raise HTTPException(status_code=404, detail="Node not found")

node.sync_status = sync_status
if sync_status == "synced":
node.last_sync_time = datetime.now()

db.commit()
db.refresh(node)
return node


def get_healthy_nodes(db: Session):
"""Get all healthy and active nodes."""
return (
db.query(Node)
.filter(Node.status == True, Node.is_healthy == True)
.all()
)


def get_nodes_needing_sync(db: Session):
"""Get nodes that need synchronization."""
return (
db.query(Node)
.filter(
Node.status == True,
Node.is_healthy == True,
Node.sync_status.in_(["pending", "failed", "never_synced"])
)
.all()
)


def get_best_node_for_download(db: Session):
"""Get the best node for downloading OVPN based on health and performance."""
from sqlalchemy import func

# Get healthy nodes sorted by response time
node = (
db.query(Node)
.filter(
Node.status == True,
Node.is_healthy == True,
Node.sync_status == "synced",
Node.consecutive_failures == 0
)
.order_by(Node.response_time.asc())
.first()
)

if not node:
# Fallback to any active node
node = db.query(Node).filter(Node.status == True).first()

return node
13 changes: 12 additions & 1 deletion backend/db/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from sqlalchemy.orm import Mapped, mapped_column
from .engine import Base
from datetime import date
from datetime import date, datetime
from typing import Optional


class User(Base):
Expand Down Expand Up @@ -33,6 +34,16 @@ class Node(Base):
port: Mapped[int] = mapped_column()
key: Mapped[str] = mapped_column(nullable=False)
status: Mapped[bool] = mapped_column(default=True)

# Health check fields
is_healthy: Mapped[bool] = mapped_column(default=True)
last_health_check: Mapped[Optional[datetime]] = mapped_column(nullable=True)
response_time: Mapped[Optional[float]] = mapped_column(nullable=True) # in seconds
consecutive_failures: Mapped[int] = mapped_column(default=0)

# Sync fields
last_sync_time: Mapped[Optional[datetime]] = mapped_column(nullable=True)
sync_status: Mapped[str] = mapped_column(default="synced") # synced, pending, failed, never_synced


class Settings(Base):
Expand Down
Loading