first commit

This commit is contained in:
Stefan Hacker
2026-02-02 09:46:35 +01:00
commit 6901dc369b
98 changed files with 13030 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
"""API routes."""
from fastapi import APIRouter
from . import auth, users, tenants, gateways, endpoints, connections
# Create main API router
api_router = APIRouter(prefix="/api")
# Include all route modules
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(tenants.router, prefix="/tenants", tags=["Tenants"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(gateways.router, prefix="/gateways", tags=["Gateways"])
api_router.include_router(endpoints.router, prefix="/endpoints", tags=["Endpoints"])
api_router.include_router(connections.router, prefix="/connections", tags=["Connections"])
+57
View File
@@ -0,0 +1,57 @@
"""Authentication API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..schemas.user import UserLogin, Token, UserResponse
from ..services.auth_service import AuthService
from .deps import get_current_user
from ..models.user import User
router = APIRouter()
@router.post("/login", response_model=Token)
def login(
credentials: UserLogin,
db: Session = Depends(get_db)
):
"""Authenticate user and return JWT tokens."""
auth_service = AuthService(db)
user = auth_service.authenticate_user(credentials.username, credentials.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"}
)
return auth_service.create_tokens(user)
@router.post("/refresh", response_model=Token)
def refresh_token(
refresh_token: str,
db: Session = Depends(get_db)
):
"""Refresh access token using refresh token."""
auth_service = AuthService(db)
tokens = auth_service.refresh_tokens(refresh_token)
if not tokens:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
headers={"WWW-Authenticate": "Bearer"}
)
return tokens
@router.get("/me", response_model=UserResponse)
def get_current_user_info(
current_user: User = Depends(get_current_user)
):
"""Get current authenticated user information."""
return current_user
+236
View File
@@ -0,0 +1,236 @@
"""Connection management API routes."""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.endpoint import Endpoint
from ..models.gateway import Gateway
from ..models.user import User, UserRole
from ..models.access import UserGatewayAccess, ConnectionLog
from ..services.vpn_service import VPNService
from ..services.firewall_service import FirewallService
from .deps import get_current_user
router = APIRouter()
class ConnectRequest(BaseModel):
"""Request to establish connection to endpoint."""
gateway_id: int
endpoint_id: int
class ConnectResponse(BaseModel):
"""Response with connection details."""
success: bool
message: str
vpn_config: str | None = None
target_ip: str | None = None
target_port: int | None = None
connection_id: int | None = None
class DisconnectRequest(BaseModel):
"""Request to disconnect from endpoint."""
connection_id: int
def user_has_gateway_access(db: Session, user: User, gateway_id: int) -> bool:
"""Check if user has access to gateway."""
if user.role in (UserRole.SUPER_ADMIN, UserRole.ADMIN):
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if user.role == UserRole.SUPER_ADMIN:
return gateway is not None
return gateway and gateway.tenant_id == user.tenant_id
access = db.query(UserGatewayAccess).filter(
UserGatewayAccess.user_id == user.id,
UserGatewayAccess.gateway_id == gateway_id
).first()
return access is not None
@router.post("/connect", response_model=ConnectResponse)
def connect_to_endpoint(
request: ConnectRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Request connection to a specific endpoint through a gateway."""
# Check gateway access
if not user_has_gateway_access(db, current_user, request.gateway_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No access to this gateway"
)
# Get gateway
gateway = db.query(Gateway).filter(Gateway.id == request.gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found"
)
if not gateway.is_online:
return ConnectResponse(
success=False,
message="Gateway is offline"
)
# Get endpoint
endpoint = db.query(Endpoint).filter(
Endpoint.id == request.endpoint_id,
Endpoint.gateway_id == request.gateway_id
).first()
if not endpoint:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Endpoint not found"
)
# NOTE: Dynamic VPN config generation has been replaced by VPN profiles.
# Gateways should have pre-provisioned VPN profiles.
# This endpoint now just logs the connection intent.
# Log connection
connection = ConnectionLog(
user_id=current_user.id,
gateway_id=gateway.id,
endpoint_id=endpoint.id,
client_ip=None # Will be updated when VPN connects
)
db.add(connection)
db.commit()
db.refresh(connection)
return ConnectResponse(
success=True,
message="Connection logged. Use the gateway's VPN profile configuration to connect.",
vpn_config=None, # VPN config is now obtained through gateway VPN profiles
target_ip=endpoint.internal_ip,
target_port=endpoint.port,
connection_id=connection.id
)
@router.post("/disconnect")
def disconnect_from_endpoint(
request: DisconnectRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Disconnect from endpoint."""
connection = db.query(ConnectionLog).filter(
ConnectionLog.id == request.connection_id,
ConnectionLog.user_id == current_user.id
).first()
if not connection:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Connection not found"
)
if connection.disconnected_at:
return {"message": "Already disconnected"}
# Revoke firewall rules if VPN IP is known
if connection.vpn_ip:
endpoint = db.query(Endpoint).filter(Endpoint.id == connection.endpoint_id).first()
gateway = db.query(Gateway).filter(Gateway.id == connection.gateway_id).first()
if endpoint and gateway:
firewall = FirewallService()
firewall.revoke_connection(
client_vpn_ip=connection.vpn_ip,
gateway_vpn_ip=gateway.vpn_ip,
target_ip=endpoint.internal_ip,
target_port=endpoint.port,
protocol=endpoint.protocol.value
)
# Disconnect VPN client
vpn_service = VPNService()
client_cn = f"client-{current_user.username}-{current_user.id}"
vpn_service.disconnect_client(client_cn)
# Update log
connection.disconnected_at = datetime.utcnow()
db.commit()
return {"message": "Disconnected successfully"}
@router.get("/active")
def list_active_connections(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List active connections for current user."""
connections = db.query(ConnectionLog).filter(
ConnectionLog.user_id == current_user.id,
ConnectionLog.disconnected_at.is_(None)
).all()
result = []
for conn in connections:
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first()
result.append({
"connection_id": conn.id,
"gateway_name": gateway.name if gateway else None,
"endpoint_name": endpoint.name if endpoint else None,
"endpoint_address": endpoint.address if endpoint else None,
"connected_at": conn.connected_at.isoformat(),
"vpn_ip": conn.vpn_ip
})
return result
@router.get("/history")
def get_connection_history(
skip: int = 0,
limit: int = 50,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get connection history for current user."""
query = db.query(ConnectionLog).filter(ConnectionLog.user_id == current_user.id)
# Admins can see all connections in their tenant
if current_user.is_admin:
if current_user.role == UserRole.SUPER_ADMIN:
query = db.query(ConnectionLog)
else:
# Filter by tenant's gateways
query = db.query(ConnectionLog).join(
Gateway,
Gateway.id == ConnectionLog.gateway_id
).filter(Gateway.tenant_id == current_user.tenant_id)
connections = query.order_by(ConnectionLog.connected_at.desc()).offset(skip).limit(limit).all()
result = []
for conn in connections:
user = db.query(User).filter(User.id == conn.user_id).first()
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first()
result.append({
"connection_id": conn.id,
"username": user.username if user else None,
"gateway_name": gateway.name if gateway else None,
"endpoint_name": endpoint.name if endpoint else None,
"connected_at": conn.connected_at.isoformat(),
"disconnected_at": conn.disconnected_at.isoformat() if conn.disconnected_at else None,
"duration_seconds": conn.duration_seconds,
"client_ip": conn.client_ip
})
return result
+87
View File
@@ -0,0 +1,87 @@
"""API dependencies for authentication and authorization."""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User, UserRole
from ..utils.security import decode_token
# Bearer token security scheme
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""Get current authenticated user from JWT token."""
token = credentials.credentials
payload = decode_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"}
)
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
headers={"WWW-Authenticate": "Bearer"}
)
user_id = payload.get("sub")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is deactivated"
)
return user
def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""Ensure user is active."""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
)
return current_user
def require_admin(
current_user: User = Depends(get_current_user)
) -> User:
"""Require admin role."""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required"
)
return current_user
def require_super_admin(
current_user: User = Depends(get_current_user)
) -> User:
"""Require super admin role."""
if current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Super admin privileges required"
)
return current_user
+231
View File
@@ -0,0 +1,231 @@
"""Endpoint management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.endpoint import Endpoint, ApplicationTemplate
from ..models.gateway import Gateway
from ..models.user import User, UserRole
from ..models.access import UserGatewayAccess, UserEndpointAccess
from ..schemas.endpoint import (
EndpointCreate, EndpointUpdate, EndpointResponse,
ApplicationTemplateResponse
)
from .deps import get_current_user, require_admin
router = APIRouter()
def user_has_gateway_access(db: Session, user: User, gateway_id: int) -> bool:
"""Check if user has access to gateway."""
if user.role == UserRole.SUPER_ADMIN:
return True
if user.role == UserRole.ADMIN:
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
return gateway and gateway.tenant_id == user.tenant_id
access = db.query(UserGatewayAccess).filter(
UserGatewayAccess.user_id == user.id,
UserGatewayAccess.gateway_id == gateway_id
).first()
return access is not None
@router.get("/templates", response_model=list[ApplicationTemplateResponse])
def list_application_templates(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List available application templates."""
templates = db.query(ApplicationTemplate).all()
return templates
@router.get("/gateway/{gateway_id}", response_model=list[EndpointResponse])
def list_endpoints_for_gateway(
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List endpoints for a gateway."""
if not user_has_gateway_access(db, current_user, gateway_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No access to this gateway"
)
endpoints = db.query(Endpoint).filter(Endpoint.gateway_id == gateway_id).all()
return endpoints
@router.post("/gateway/{gateway_id}", response_model=EndpointResponse, status_code=status.HTTP_201_CREATED)
def create_endpoint(
gateway_id: int,
endpoint_data: EndpointCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Create a new endpoint for a gateway."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found"
)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if gateway.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot add endpoints to gateways from other tenants"
)
endpoint = Endpoint(
**endpoint_data.model_dump(),
gateway_id=gateway_id
)
db.add(endpoint)
db.commit()
db.refresh(endpoint)
return endpoint
@router.get("/{endpoint_id}", response_model=EndpointResponse)
def get_endpoint(
endpoint_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get endpoint by ID."""
endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first()
if not endpoint:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Endpoint not found"
)
if not user_has_gateway_access(db, current_user, endpoint.gateway_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No access to this endpoint"
)
return endpoint
@router.put("/{endpoint_id}", response_model=EndpointResponse)
def update_endpoint(
endpoint_id: int,
endpoint_data: EndpointUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Update endpoint."""
endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first()
if not endpoint:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Endpoint not found"
)
gateway = db.query(Gateway).filter(Gateway.id == endpoint.gateway_id).first()
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if gateway.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot modify endpoints from other tenants"
)
update_data = endpoint_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(endpoint, field, value)
db.commit()
db.refresh(endpoint)
return endpoint
@router.delete("/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_endpoint(
endpoint_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Delete endpoint."""
endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first()
if not endpoint:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Endpoint not found"
)
gateway = db.query(Gateway).filter(Gateway.id == endpoint.gateway_id).first()
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if gateway.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete endpoints from other tenants"
)
db.delete(endpoint)
db.commit()
@router.post("/{endpoint_id}/access/{user_id}", status_code=status.HTTP_201_CREATED)
def grant_endpoint_access(
endpoint_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Grant user access to specific endpoint (fine-grained control)."""
endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first()
if not endpoint:
raise HTTPException(status_code=404, detail="Endpoint not found")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
existing = db.query(UserEndpointAccess).filter(
UserEndpointAccess.endpoint_id == endpoint_id,
UserEndpointAccess.user_id == user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Access already granted")
access = UserEndpointAccess(
user_id=user_id,
endpoint_id=endpoint_id,
granted_by_id=current_user.id
)
db.add(access)
db.commit()
return {"message": "Access granted"}
@router.delete("/{endpoint_id}/access/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def revoke_endpoint_access(
endpoint_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Revoke user access to endpoint."""
access = db.query(UserEndpointAccess).filter(
UserEndpointAccess.endpoint_id == endpoint_id,
UserEndpointAccess.user_id == user_id
).first()
if not access:
raise HTTPException(status_code=404, detail="Access not found")
db.delete(access)
db.commit()
+335
View File
@@ -0,0 +1,335 @@
"""Gateway management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status, Response
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.gateway import Gateway
from ..models.user import User, UserRole
from ..models.access import UserGatewayAccess
from ..models.vpn_connection_log import VPNConnectionLog
from ..schemas.gateway import GatewayCreate, GatewayUpdate, GatewayResponse, GatewayStatus
from ..services.vpn_sync_service import VPNSyncService
from .deps import get_current_user, require_admin
router = APIRouter()
def get_accessible_gateways_query(db: Session, user: User):
"""Get query for gateways accessible by user."""
if user.role == UserRole.SUPER_ADMIN:
return db.query(Gateway)
elif user.role == UserRole.ADMIN:
return db.query(Gateway).filter(Gateway.tenant_id == user.tenant_id)
else:
# Technicians and viewers only see assigned gateways
return db.query(Gateway).join(
UserGatewayAccess,
UserGatewayAccess.gateway_id == Gateway.id
).filter(UserGatewayAccess.user_id == user.id)
@router.get("/", response_model=list[GatewayResponse])
def list_gateways(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List gateways accessible by current user."""
query = get_accessible_gateways_query(db, current_user)
gateways = query.offset(skip).limit(limit).all()
return gateways
@router.get("/status", response_model=list[GatewayStatus])
def get_gateways_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get online status of all accessible gateways."""
query = get_accessible_gateways_query(db, current_user)
gateways = query.all()
return [
GatewayStatus(
id=gw.id,
name=gw.name,
is_online=gw.is_online,
last_seen=gw.last_seen,
vpn_ip=gw.vpn_ip
)
for gw in gateways
]
@router.post("/", response_model=GatewayResponse, status_code=status.HTTP_201_CREATED)
def create_gateway(
gateway_data: GatewayCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Create a new gateway (admin only)."""
# Determine tenant_id
if current_user.role == UserRole.SUPER_ADMIN:
# Super admin must specify tenant_id via query param or we use a default
tenant_id = current_user.tenant_id or 1 # Fallback
else:
tenant_id = current_user.tenant_id
# Generate VPN certificate CN
vpn_cert_cn = f"gateway-{gateway_data.name.lower().replace(' ', '-')}"
gateway = Gateway(
**gateway_data.model_dump(),
tenant_id=tenant_id,
vpn_cert_cn=vpn_cert_cn
)
db.add(gateway)
db.commit()
db.refresh(gateway)
return gateway
@router.get("/{gateway_id}", response_model=GatewayResponse)
def get_gateway(
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get gateway by ID."""
query = get_accessible_gateways_query(db, current_user)
gateway = query.filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found or access denied"
)
return gateway
@router.put("/{gateway_id}", response_model=GatewayResponse)
def update_gateway(
gateway_id: int,
gateway_data: GatewayUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Update gateway (admin only)."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found"
)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if gateway.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot modify gateways from other tenants"
)
update_data = gateway_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(gateway, field, value)
db.commit()
db.refresh(gateway)
return gateway
@router.delete("/{gateway_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_gateway(
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Delete gateway (admin only)."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found"
)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if gateway.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete gateways from other tenants"
)
db.delete(gateway)
db.commit()
@router.get("/{gateway_id}/provision")
def download_provisioning_package(
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Download provisioning package for gateway.
DEPRECATED: Use VPN profiles for provisioning.
GET /api/gateways/{gateway_id}/profiles/{profile_id}/provision
"""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found"
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Provisioning is now done through VPN profiles. "
"Please create a VPN profile for this gateway first, "
"then use GET /api/gateways/{gateway_id}/profiles/{profile_id}/provision"
)
@router.post("/{gateway_id}/access/{user_id}", status_code=status.HTTP_201_CREATED)
def grant_gateway_access(
gateway_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Grant user access to gateway."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(status_code=404, detail="Gateway not found")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Check existing access
existing = db.query(UserGatewayAccess).filter(
UserGatewayAccess.gateway_id == gateway_id,
UserGatewayAccess.user_id == user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Access already granted")
access = UserGatewayAccess(
user_id=user_id,
gateway_id=gateway_id,
granted_by_id=current_user.id
)
db.add(access)
db.commit()
return {"message": "Access granted"}
@router.delete("/{gateway_id}/access/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def revoke_gateway_access(
gateway_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Revoke user access to gateway."""
access = db.query(UserGatewayAccess).filter(
UserGatewayAccess.gateway_id == gateway_id,
UserGatewayAccess.user_id == user_id
).first()
if not access:
raise HTTPException(status_code=404, detail="Access not found")
db.delete(access)
db.commit()
@router.get("/{gateway_id}/vpn-logs")
def get_gateway_vpn_logs(
gateway_id: int,
limit: int = 50,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get VPN connection logs for a gateway."""
# Check access
query = get_accessible_gateways_query(db, current_user)
gateway = query.filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found or access denied"
)
sync_service = VPNSyncService(db)
logs = sync_service.get_gateway_connection_logs(gateway_id, limit=limit)
return [
{
"id": log.id,
"profile_id": log.vpn_profile_id,
"profile_name": log.vpn_profile.name if log.vpn_profile else None,
"server_id": log.vpn_server_id,
"server_name": log.vpn_server.name if log.vpn_server else None,
"common_name": log.common_name,
"real_address": log.real_address,
"vpn_ip": log.vpn_ip,
"connected_at": log.connected_at.isoformat() if log.connected_at else None,
"disconnected_at": log.disconnected_at.isoformat() if log.disconnected_at else None,
"duration_seconds": log.duration_seconds,
"bytes_received": log.bytes_received,
"bytes_sent": log.bytes_sent,
"is_active": log.is_active
}
for log in logs
]
@router.get("/{gateway_id}/vpn-status")
def get_gateway_vpn_status(
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get current VPN connection status for a gateway."""
# Check access
query = get_accessible_gateways_query(db, current_user)
gateway = query.filter(Gateway.id == gateway_id).first()
if not gateway:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Gateway not found or access denied"
)
# Sync connections first
sync_service = VPNSyncService(db)
sync_service.sync_all_connections()
# Get active connection
active_log = db.query(VPNConnectionLog).filter(
VPNConnectionLog.gateway_id == gateway_id,
VPNConnectionLog.disconnected_at.is_(None)
).first()
if active_log:
return {
"is_connected": True,
"profile_name": active_log.vpn_profile.name if active_log.vpn_profile else None,
"server_name": active_log.vpn_server.name if active_log.vpn_server else None,
"real_address": active_log.real_address,
"connected_since": active_log.connected_at.isoformat() if active_log.connected_at else None,
"bytes_received": active_log.bytes_received,
"bytes_sent": active_log.bytes_sent
}
else:
return {
"is_connected": False,
"last_connection": gateway.last_seen.isoformat() if gateway.last_seen else None
}
+363
View File
@@ -0,0 +1,363 @@
"""Internal API endpoints for container-to-container communication.
These endpoints are used by OpenVPN containers to fetch their configuration.
They should only be accessible from within the Docker network.
"""
import os
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response, Query
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.vpn_server import VPNServer, VPNServerStatus
from ..models.vpn_profile import VPNProfile
from ..services.vpn_server_service import VPNServerService
from ..services.vpn_sync_service import VPNSyncService
from ..services.certificate_service import CertificateService
# Log directory (shared volume with OpenVPN container)
LOG_DIR = Path("/var/log/openvpn")
router = APIRouter(prefix="/api/internal", tags=["internal"])
@router.get("/health")
async def health_check():
"""Health check endpoint for container startup."""
return {"status": "healthy"}
@router.get("/vpn-servers/active")
async def get_active_servers(db: Session = Depends(get_db)):
"""Get list of all active and ready VPN servers.
Used by OpenVPN container to discover which servers to start.
"""
servers = db.query(VPNServer).filter(
VPNServer.is_active == True
).all()
return [
{
"id": s.id,
"name": s.name,
"port": s.port,
"protocol": s.protocol.value,
"management_port": s.management_port,
"vpn_network": s.vpn_network,
"vpn_netmask": s.vpn_netmask,
"is_ready": s.is_ready,
"has_ca": s.certificate_authority is not None and s.certificate_authority.is_ready,
"has_cert": s.server_cert is not None and s.server_key is not None
}
for s in servers
]
@router.get("/vpn-servers/{server_id}/config")
async def get_server_config(
server_id: int,
db: Session = Depends(get_db)
):
"""Get OpenVPN server configuration file."""
service = VPNServerService(db)
server = service.get_server_by_id(server_id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.server_cert or not server.server_key:
raise HTTPException(status_code=400, detail="Server certificate not generated")
config = service.generate_server_config(server)
# Update server status
server.status = VPNServerStatus.STARTING
db.commit()
return Response(content=config, media_type="text/plain")
@router.get("/vpn-servers/{server_id}/ca")
async def get_server_ca(
server_id: int,
db: Session = Depends(get_db)
):
"""Get CA certificate for a VPN server."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.certificate_authority or not server.certificate_authority.ca_cert:
raise HTTPException(status_code=400, detail="CA not available")
return Response(
content=server.certificate_authority.ca_cert,
media_type="application/x-pem-file"
)
@router.get("/vpn-servers/{server_id}/cert")
async def get_server_cert(
server_id: int,
db: Session = Depends(get_db)
):
"""Get server certificate."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.server_cert:
raise HTTPException(status_code=400, detail="Server certificate not generated")
return Response(
content=server.server_cert,
media_type="application/x-pem-file"
)
@router.get("/vpn-servers/{server_id}/key")
async def get_server_key(
server_id: int,
db: Session = Depends(get_db)
):
"""Get server private key."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.server_key:
raise HTTPException(status_code=400, detail="Server key not generated")
return Response(
content=server.server_key,
media_type="application/x-pem-file"
)
@router.get("/vpn-servers/{server_id}/dh")
async def get_server_dh(
server_id: int,
db: Session = Depends(get_db)
):
"""Get DH parameters."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.certificate_authority or not server.certificate_authority.dh_params:
raise HTTPException(status_code=400, detail="DH parameters not available")
return Response(
content=server.certificate_authority.dh_params,
media_type="application/x-pem-file"
)
@router.get("/vpn-servers/{server_id}/ta")
async def get_server_ta(
server_id: int,
db: Session = Depends(get_db)
):
"""Get TLS-Auth key."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.ta_key:
raise HTTPException(status_code=400, detail="TA key not available")
return Response(
content=server.ta_key,
media_type="text/plain"
)
@router.get("/vpn-servers/{server_id}/crl")
async def get_server_crl(
server_id: int,
db: Session = Depends(get_db)
):
"""Get Certificate Revocation List."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.certificate_authority:
raise HTTPException(status_code=400, detail="CA not available")
cert_service = CertificateService(db)
crl = cert_service.get_crl(server.certificate_authority)
return Response(
content=crl,
media_type="application/x-pem-file"
)
@router.post("/vpn-servers/{server_id}/started")
async def notify_server_started(
server_id: int,
db: Session = Depends(get_db)
):
"""Notify that server has started successfully."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
server.status = VPNServerStatus.RUNNING
db.commit()
return {"status": "ok"}
@router.post("/vpn-servers/{server_id}/stopped")
async def notify_server_stopped(
server_id: int,
db: Session = Depends(get_db)
):
"""Notify that server has stopped."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
server.status = VPNServerStatus.STOPPED
db.commit()
return {"status": "ok"}
@router.get("/vpn-servers/{server_id}/logs")
async def get_server_logs(
server_id: int,
lines: int = Query(default=100, le=1000),
db: Session = Depends(get_db)
):
"""Get OpenVPN server log file.
Args:
server_id: VPN server ID
lines: Number of lines to return (max 1000)
"""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
log_file = LOG_DIR / f"server-{server_id}.log"
if not log_file.exists():
return {"lines": [], "message": "Log file not found"}
try:
# Read last N lines
with open(log_file, 'r') as f:
all_lines = f.readlines()
log_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
return {
"server_id": server_id,
"server_name": server.name,
"total_lines": len(all_lines),
"returned_lines": len(log_lines),
"lines": [line.rstrip() for line in log_lines]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error reading log: {str(e)}")
@router.get("/vpn-servers/{server_id}/logs/raw")
async def get_server_logs_raw(
server_id: int,
lines: int = Query(default=100, le=5000),
db: Session = Depends(get_db)
):
"""Get OpenVPN server log file as plain text."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
log_file = LOG_DIR / f"server-{server_id}.log"
if not log_file.exists():
return Response(content="Log file not found", media_type="text/plain")
try:
with open(log_file, 'r') as f:
all_lines = f.readlines()
log_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
return Response(
content="".join(log_lines),
media_type="text/plain"
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error reading log: {str(e)}")
@router.get("/logs/supervisord")
async def get_supervisord_logs(
lines: int = Query(default=100, le=1000)
):
"""Get supervisord log file."""
log_file = LOG_DIR / "supervisord.log"
if not log_file.exists():
return {"lines": [], "message": "Supervisord log not found"}
try:
with open(log_file, 'r') as f:
all_lines = f.readlines()
log_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
return {
"total_lines": len(all_lines),
"returned_lines": len(log_lines),
"lines": [line.rstrip() for line in log_lines]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error reading log: {str(e)}")
@router.get("/debug/sync")
async def debug_sync(db: Session = Depends(get_db)):
"""Debug endpoint to check VPN sync status."""
sync_service = VPNSyncService(db)
vpn_service = VPNServerService(db)
# Get all profiles with their CNs
profiles = db.query(VPNProfile).all()
profile_cns = [{"id": p.id, "name": p.name, "cert_cn": p.cert_cn, "gateway_id": p.gateway_id} for p in profiles]
# Get connected clients from all servers
servers = db.query(VPNServer).filter(VPNServer.is_active == True).all()
connected_clients = []
for server in servers:
try:
clients = vpn_service.get_connected_clients(server)
for client in clients:
client['server_id'] = server.id
client['server_name'] = server.name
connected_clients.append(client)
except Exception as e:
connected_clients.append({"server_id": server.id, "error": str(e)})
# Run sync and get result
sync_result = sync_service.sync_all_connections()
return {
"profiles": profile_cns,
"connected_clients": connected_clients,
"sync_result": sync_result
}
+103
View File
@@ -0,0 +1,103 @@
"""Tenant management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.tenant import Tenant
from ..models.user import User
from ..schemas.tenant import TenantCreate, TenantUpdate, TenantResponse
from .deps import require_super_admin
router = APIRouter()
@router.get("/", response_model=list[TenantResponse])
def list_tenants(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin)
):
"""List all tenants (super admin only)."""
tenants = db.query(Tenant).offset(skip).limit(limit).all()
return tenants
@router.post("/", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
def create_tenant(
tenant_data: TenantCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin)
):
"""Create a new tenant (super admin only)."""
# Check if name already exists
existing = db.query(Tenant).filter(Tenant.name == tenant_data.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant with this name already exists"
)
tenant = Tenant(**tenant_data.model_dump())
db.add(tenant)
db.commit()
db.refresh(tenant)
return tenant
@router.get("/{tenant_id}", response_model=TenantResponse)
def get_tenant(
tenant_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin)
):
"""Get tenant by ID (super admin only)."""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
return tenant
@router.put("/{tenant_id}", response_model=TenantResponse)
def update_tenant(
tenant_id: int,
tenant_data: TenantUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin)
):
"""Update tenant (super admin only)."""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
update_data = tenant_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(tenant, field, value)
db.commit()
db.refresh(tenant)
return tenant
@router.delete("/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_tenant(
tenant_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin)
):
"""Delete tenant (super admin only)."""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
db.delete(tenant)
db.commit()
+176
View File
@@ -0,0 +1,176 @@
"""User management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User, UserRole
from ..schemas.user import UserCreate, UserUpdate, UserResponse
from ..utils.security import get_password_hash
from .deps import get_current_user, require_admin
router = APIRouter()
@router.get("/", response_model=list[UserResponse])
def list_users(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""List users (admins see their tenant's users, super admins see all)."""
query = db.query(User)
# Filter by tenant for non-super-admins
if current_user.role != UserRole.SUPER_ADMIN:
query = query.filter(User.tenant_id == current_user.tenant_id)
users = query.offset(skip).limit(limit).all()
return users
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
user_data: UserCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Create a new user."""
# Check permissions
if current_user.role != UserRole.SUPER_ADMIN:
# Regular admins can only create users in their own tenant
if user_data.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot create users in other tenants"
)
# Cannot create super admins
if user_data.role == UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot create super admin users"
)
# Check if username exists
existing = db.query(User).filter(User.username == user_data.username).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already exists"
)
# Check if email exists
existing = db.query(User).filter(User.email == user_data.email).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already exists"
)
user = User(
username=user_data.username,
email=user_data.email,
password_hash=get_password_hash(user_data.password),
full_name=user_data.full_name,
role=user_data.role,
tenant_id=user_data.tenant_id
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.get("/{user_id}", response_model=UserResponse)
def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Get user by ID."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if user.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot access users from other tenants"
)
return user
@router.put("/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
user_data: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Update user."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if user.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot modify users from other tenants"
)
update_data = user_data.model_dump(exclude_unset=True)
# Hash password if provided
if "password" in update_data:
update_data["password_hash"] = get_password_hash(update_data.pop("password"))
for field, value in update_data.items():
setattr(user, field, value)
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin)
):
"""Delete user."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Cannot delete yourself
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account"
)
# Check tenant access
if current_user.role != UserRole.SUPER_ADMIN:
if user.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete users from other tenants"
)
db.delete(user)
db.commit()