Add complete project structure and all files

This commit is contained in:
2025-12-15 13:03:29 +05:00
parent dc4c2521c7
commit f5a3903f5d
139 changed files with 72714 additions and 2 deletions

View File

@@ -0,0 +1,22 @@
# API routers
from . import auth
from . import dashboard
from . import proxmox
from . import templates
from . import ipam
from . import logs
from . import settings
from . import notifications
from . import users
__all__ = [
"auth",
"dashboard",
"proxmox",
"templates",
"ipam",
"logs",
"settings",
"notifications",
"users"
]

473
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,473 @@
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from loguru import logger
from ..db import get_db
from ..models import User
from ..schemas import UserCreate, UserResponse, LoginRequest, Token
from ..auth import (
get_password_hash,
verify_password,
create_access_token,
get_current_user,
get_current_active_admin,
get_client_ip,
get_current_session_token
)
from ..config import settings, utcnow
from ..logging_service import LoggingService
from ..template_helpers import add_i18n_context
from ..services.security_service import SecurityService
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/login", response_class=HTMLResponse, include_in_schema=False)
def login_page(request: Request):
"""Login page"""
context = {
"request": request,
"panel_name": settings.PANEL_NAME,
"version": settings.VERSION,
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("login.html", context)
@router.post("/api/auth/login", response_model=Token)
def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_db)):
"""
Authenticate user and return JWT token with session management
"""
# Get client info
client_ip = get_client_ip(request)
user_agent = request.headers.get("user-agent", "")[:500]
# Check if IP is blocked
is_blocked, block_reason = SecurityService.is_ip_blocked(db, client_ip)
if is_blocked:
logger.warning(f"Blocked IP {client_ip} attempted login: {block_reason}")
SecurityService.record_login_attempt(
db, client_ip, login_data.username, False, "IP blocked", user_agent
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied: {block_reason}"
)
# Check for brute force
should_block, reason = SecurityService.check_brute_force(db, client_ip, login_data.username)
if should_block:
block_duration = SecurityService.get_setting_int(db, "ip_block_duration_minutes", 60)
SecurityService.block_ip(db, client_ip, reason, "system", block_duration)
SecurityService.record_login_attempt(
db, client_ip, login_data.username, False, "Brute force detected", user_agent
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Too many failed attempts. IP blocked temporarily."
)
# Find user
user = db.query(User).filter(User.username == login_data.username).first()
if not user:
SecurityService.record_login_attempt(
db, client_ip, login_data.username, False, "User not found", user_agent
)
LoggingService.log_auth(
db=db,
action="login_failed",
username=login_data.username,
ip_address=client_ip,
success=False,
error_message="User not found",
user_agent=user_agent
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if account is locked
is_locked, locked_until = SecurityService.is_account_locked(user)
if is_locked:
SecurityService.record_login_attempt(
db, client_ip, login_data.username, False, "Account locked", user_agent
)
LoggingService.log_auth(
db=db,
action="login_blocked",
username=login_data.username,
ip_address=client_ip,
success=False,
error_message="Account locked",
user_agent=user_agent,
user_id=user.id
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Account is locked until {locked_until.strftime('%H:%M:%S')}"
)
# Verify password
if not verify_password(login_data.password, user.hashed_password):
SecurityService.record_login_attempt(
db, client_ip, login_data.username, False, "Wrong password", user_agent
)
# Increment failed attempts
was_locked = SecurityService.increment_failed_attempts(db, user)
LoggingService.log_auth(
db=db,
action="login_failed",
username=login_data.username,
ip_address=client_ip,
success=False,
error_message="Incorrect password" + (" - account locked" if was_locked else ""),
user_agent=user_agent,
user_id=user.id
)
if was_locked:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account locked due to too many failed attempts"
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if user is active
if not user.is_active:
SecurityService.record_login_attempt(
db, client_ip, login_data.username, False, "Account disabled", user_agent
)
LoggingService.log_auth(
db=db,
action="login_blocked",
username=login_data.username,
ip_address=client_ip,
success=False,
error_message="Account disabled",
user_agent=user_agent,
user_id=user.id
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled"
)
# SUCCESS - Reset failed attempts
SecurityService.reset_failed_attempts(db, user)
# Create session
session, was_other_terminated = SecurityService.create_session(
db, user, client_ip, user_agent
)
# Update last login
user.last_login = utcnow()
db.commit()
# Create access token with session binding
session_timeout = SecurityService.get_setting_int(db, "session_timeout_minutes", 60)
access_token_expires = timedelta(minutes=session_timeout)
access_token = create_access_token(
data={"sub": user.username},
session_token=session.session_token,
expires_delta=access_token_expires
)
# Record successful login
SecurityService.record_login_attempt(
db, client_ip, login_data.username, True, None, user_agent
)
# Log successful login
LoggingService.log_auth(
db=db,
action="login",
username=user.username,
ip_address=client_ip,
success=True,
user_agent=user_agent,
user_id=user.id,
details={"session_id": session.id, "other_session_terminated": was_other_terminated}
)
logger.info(f"User {user.username} logged in from {client_ip}" +
(" (other session terminated)" if was_other_terminated else ""))
return {
"access_token": access_token,
"token_type": "bearer",
"session_id": session.id,
"other_session_terminated": was_other_terminated
}
@router.post("/api/auth/logout")
def logout(
request: Request,
current_user: User = Depends(get_current_user),
session_token: str = Depends(get_current_session_token),
db: Session = Depends(get_db)
):
"""Logout user and terminate current session only"""
client_ip = get_client_ip(request)
# Terminate only current session (allow multiple sessions)
if session_token:
SecurityService.terminate_session(db, session_token)
LoggingService.log_auth(
db=db,
action="logout",
username=current_user.username,
ip_address=client_ip,
success=True,
user_id=current_user.id
)
logger.info(f"User {current_user.username} logged out")
return {"message": "Successfully logged out"}
@router.get("/api/auth/me")
def get_current_user_info(current_user: User = Depends(get_current_user)):
"""Get current user information with permissions"""
permissions = {}
if current_user.role:
permissions = current_user.role.permissions or {}
elif current_user.is_admin:
# Admin has all permissions
from ..services.security_service import SecurityService
all_perms = SecurityService.get_all_permissions()
for category, perms in all_perms.items():
for perm in perms:
permissions[perm] = True
return {
"id": current_user.id,
"username": current_user.username,
"email": current_user.email,
"full_name": current_user.full_name,
"is_admin": current_user.is_admin,
"is_active": current_user.is_active,
"role": {
"id": current_user.role.id if current_user.role else None,
"name": current_user.role.name if current_user.role else ("admin" if current_user.is_admin else "user"),
"display_name": current_user.role.display_name if current_user.role else ("Administrator" if current_user.is_admin else "User")
},
"permissions": permissions,
"require_password_change": current_user.require_password_change,
"last_login": current_user.last_login.isoformat() if current_user.last_login else None,
"created_at": current_user.created_at.isoformat() if current_user.created_at else None
}
@router.get("/api/auth/sessions")
def get_my_sessions(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""Get current user's active sessions"""
sessions = SecurityService.get_user_sessions(db, current_user.id)
return [{
"id": s.id,
"ip_address": s.ip_address,
"device_info": s.device_info,
"created_at": s.created_at.isoformat() if s.created_at else None,
"last_activity": s.last_activity.isoformat() if s.last_activity else None,
"expires_at": s.expires_at.isoformat() if s.expires_at else None
} for s in sessions]
@router.post("/api/auth/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def register_user(
user_data: UserCreate,
request: Request,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_active_admin)
):
"""
Register a new user (admin only)
"""
client_ip = get_client_ip(request)
# Check if username already exists
if db.query(User).filter(User.username == user_data.username).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Check if email already exists
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Validate password
is_valid, errors = SecurityService.validate_password(db, user_data.password)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="; ".join(errors)
)
# Create new user
hashed_password = get_password_hash(user_data.password)
new_user = User(
username=user_data.username,
email=user_data.email,
full_name=user_data.full_name,
hashed_password=hashed_password,
is_active=True,
is_admin=False,
last_password_change=utcnow()
)
db.add(new_user)
db.commit()
db.refresh(new_user)
LoggingService.log_auth(
db=db,
action="user_created",
username=current_admin.username,
ip_address=client_ip,
success=True,
user_id=current_admin.id,
details={"new_user": new_user.username}
)
logger.info(f"New user registered: {new_user.username} by admin {current_admin.username}")
return new_user
@router.get("/api/users", response_model=list[UserResponse])
def list_users(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_active_admin)
):
"""
List all users (admin only)
"""
users = db.query(User).all()
return users
@router.delete("/api/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_active_admin)
):
"""
Delete a user (admin only)
"""
client_ip = get_client_ip(request)
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"
)
# Prevent deleting yourself
if user.id == current_admin.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account"
)
username = user.username
# Terminate sessions before deletion
SecurityService.terminate_all_user_sessions(db, user_id)
db.delete(user)
db.commit()
LoggingService.log_auth(
db=db,
action="user_deleted",
username=current_admin.username,
ip_address=client_ip,
success=True,
user_id=current_admin.id,
details={"deleted_user": username}
)
logger.info(f"User {username} deleted by admin {current_admin.username}")
return None
@router.post("/api/auth/change-password")
def change_password(
old_password: str = Form(...),
new_password: str = Form(...),
request: Request = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Change user password
"""
client_ip = get_client_ip(request) if request else "unknown"
# Verify old password
if not verify_password(old_password, current_user.hashed_password):
LoggingService.log_auth(
db=db,
action="password_change_failed",
username=current_user.username,
ip_address=client_ip,
success=False,
error_message="Incorrect current password",
user_id=current_user.id
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect password"
)
# Validate new password
is_valid, errors = SecurityService.validate_password(db, new_password)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="; ".join(errors)
)
# Update password
current_user.hashed_password = get_password_hash(new_password)
current_user.last_password_change = utcnow()
current_user.require_password_change = False
db.commit()
LoggingService.log_auth(
db=db,
action="password_changed",
username=current_user.username,
ip_address=client_ip,
success=True,
user_id=current_user.id
)
logger.info(f"User {current_user.username} changed password")
return {"message": "Password changed successfully"}

View File

@@ -0,0 +1,53 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import ProxmoxServer
from ..template_helpers import add_i18n_context
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
def dashboard(request: Request, db: Session = Depends(get_db)):
total_servers = db.query(ProxmoxServer).count()
online_servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online == True).count()
offline_servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online == False).count()
recent_servers = db.query(ProxmoxServer).order_by(ProxmoxServer.id.desc()).limit(6).all()
stats = {
"total_servers": total_servers,
"online_servers": online_servers,
"offline_servers": offline_servers,
}
lang = request.cookies.get("language", "en")
from ..i18n import t
context = {
"request": request,
"stats": stats,
"recent_servers": recent_servers,
"page_title": t('nav_dashboard', lang),
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("dashboard.html", context)
@router.get("/containers", response_class=HTMLResponse, include_in_schema=False)
def containers(request: Request):
lang = request.cookies.get("language", "en")
from ..i18n import t
context = {
"request": request,
"page_title": t('nav_docker', lang),
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("containers.html", context)

898
backend/app/api/ipam.py Normal file
View File

@@ -0,0 +1,898 @@
"""
IPAM API Routes
IP Address Management endpoints for networks, pools, and allocations.
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory, ProxmoxServer, User, VMInstance
from ..schemas import (
IPAMNetworkCreate, IPAMNetworkUpdate, IPAMNetworkResponse, IPAMNetworkStats,
IPAMPoolCreate, IPAMPoolUpdate, IPAMPoolResponse,
IPAMAllocationCreate, IPAMAllocationUpdate, IPAMAllocationResponse,
IPAMHistoryResponse, IPAMAutoAllocateRequest
)
from ..ipam_service import IPAMService
from ..auth import get_current_user, PermissionChecker
from ..template_helpers import add_i18n_context
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# ==================== HTML Pages ====================
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
async def ipam_dashboard(request: Request):
"""IPAM Dashboard page"""
from ..i18n import t
lang = request.cookies.get("language", "en")
context = {
"request": request,
"page_title": t('nav_ipam', lang),
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("ipam_dashboard.html", context)
@router.get("/networks", response_class=HTMLResponse, include_in_schema=False)
async def ipam_networks_page(request: Request):
"""IPAM Networks management page"""
from ..i18n import t
lang = request.cookies.get("language", "en")
context = {
"request": request,
"page_title": t('nav_ipam', lang),
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("ipam_networks.html", context)
@router.get("/network/{network_id}", response_class=HTMLResponse, include_in_schema=False)
async def ipam_network_detail(request: Request, network_id: int):
"""IPAM Network detail page with IP grid"""
from ..i18n import t
lang = request.cookies.get("language", "en")
context = {
"request": request,
"network_id": network_id,
"page_title": t('nav_ipam', lang),
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("ipam_network_detail.html", context)
@router.get("/allocations", response_class=HTMLResponse, include_in_schema=False)
async def ipam_allocations_page(request: Request):
"""IPAM Allocations page"""
from ..i18n import t
lang = request.cookies.get("language", "en")
context = {
"request": request,
"page_title": t('nav_ipam', lang),
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("ipam_allocations.html", context)
@router.get("/history", response_class=HTMLResponse, include_in_schema=False)
async def ipam_history_page(request: Request):
"""IPAM History page"""
from ..i18n import t
lang = request.cookies.get("language", "en")
context = {
"request": request,
"page_title": t('nav_ipam', lang),
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("ipam_history.html", context)
# ==================== Network API ====================
@router.get("/api/networks", response_model=List[IPAMNetworkResponse])
async def get_networks(
is_active: Optional[bool] = None,
proxmox_server_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
):
"""Get all IPAM networks"""
query = db.query(IPAMNetwork)
if is_active is not None:
query = query.filter(IPAMNetwork.is_active == is_active)
if proxmox_server_id:
query = query.filter(IPAMNetwork.proxmox_server_id == proxmox_server_id)
networks = query.order_by(IPAMNetwork.name).all()
# Get server names for networks
server_ids = [n.proxmox_server_id for n in networks if n.proxmox_server_id]
servers = {}
if server_ids:
server_records = db.query(ProxmoxServer).filter(ProxmoxServer.id.in_(server_ids)).all()
servers = {s.id: s.name for s in server_records}
# Add computed stats to each network
ipam = IPAMService(db)
result = []
for network in networks:
stats = ipam.get_network_stats(network.id)
network_dict = {
**network.__dict__,
'total_ips': stats.get('total_ips', 0),
'used_ips': stats.get('allocated_ips', 0) + stats.get('reserved_ips', 0),
'available_ips': stats.get('available_ips', 0),
'utilization_percent': stats.get('utilization_percent', 0),
'server_name': servers.get(network.proxmox_server_id) if network.proxmox_server_id else None
}
result.append(network_dict)
return result
@router.get("/api/networks/{network_id}", response_model=IPAMNetworkResponse)
async def get_network(
network_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
):
"""Get a specific IPAM network"""
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
ipam = IPAMService(db)
stats = ipam.get_network_stats(network_id)
return {
**network.__dict__,
'total_ips': stats.get('total_ips', 0),
'used_ips': stats.get('allocated_ips', 0) + stats.get('reserved_ips', 0),
'available_ips': stats.get('available_ips', 0),
'utilization_percent': stats.get('utilization_percent', 0)
}
@router.get("/api/networks/{network_id}/stats", response_model=IPAMNetworkStats)
async def get_network_stats(
network_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
):
"""Get detailed statistics for a network"""
ipam = IPAMService(db)
stats = ipam.get_network_stats(network_id)
if not stats:
raise HTTPException(status_code=404, detail="Network not found")
return stats
@router.get("/api/networks/{network_id}/ip-map")
async def get_network_ip_map(
network_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
):
"""Get IP address map for visualization"""
ipam = IPAMService(db)
ip_map = ipam.get_network_ip_map(network_id)
if not ip_map:
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
return {"network_id": network_id, "ip_map": ip_map}
@router.post("/api/networks", response_model=IPAMNetworkResponse)
async def create_network(
network_data: IPAMNetworkCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
"""Create a new IPAM network"""
# Check for duplicate network CIDR
existing = db.query(IPAMNetwork).filter(IPAMNetwork.network == network_data.network).first()
if existing:
raise HTTPException(status_code=400, detail=f"Network {network_data.network} already exists")
network = IPAMNetwork(**network_data.model_dump())
db.add(network)
db.commit()
db.refresh(network)
logger.info(f"User {current_user.username} created network {network.name} ({network.network})")
return network
@router.put("/api/networks/{network_id}", response_model=IPAMNetworkResponse)
async def update_network(
network_id: int,
network_data: IPAMNetworkUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
"""Update an IPAM network"""
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
update_data = network_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(network, field, value)
db.commit()
db.refresh(network)
logger.info(f"User {current_user.username} updated network {network.name}")
return network
@router.delete("/api/networks/{network_id}")
async def delete_network(
network_id: int,
force: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
"""Delete an IPAM network"""
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
# Check for existing allocations
alloc_count = db.query(IPAMAllocation).filter(IPAMAllocation.network_id == network_id).count()
if alloc_count > 0 and not force:
raise HTTPException(
status_code=400,
detail=f"Network has {alloc_count} allocations. Use force=true to delete anyway."
)
# Delete related data
db.query(IPAMAllocation).filter(IPAMAllocation.network_id == network_id).delete()
db.query(IPAMPool).filter(IPAMPool.network_id == network_id).delete()
db.query(IPAMHistory).filter(IPAMHistory.network_id == network_id).delete()
db.delete(network)
db.commit()
logger.info(f"User {current_user.username} deleted network {network.name}")
return {"message": f"Network {network.name} deleted"}
# ==================== Pool API ====================
@router.get("/api/pools", response_model=List[IPAMPoolResponse])
async def get_pools(
network_id: Optional[int] = None,
is_active: Optional[bool] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
):
"""Get all IP pools"""
query = db.query(IPAMPool)
if network_id:
query = query.filter(IPAMPool.network_id == network_id)
if is_active is not None:
query = query.filter(IPAMPool.is_active == is_active)
pools = query.order_by(IPAMPool.network_id, IPAMPool.range_start).all()
# Add stats to each pool
ipam = IPAMService(db)
result = []
for pool in pools:
stats = ipam.get_pool_stats(pool.id)
pool_dict = {
**pool.__dict__,
'total_ips': stats.get('total_ips', 0),
'used_ips': stats.get('used_ips', 0),
'available_ips': stats.get('available_ips', 0)
}
result.append(pool_dict)
return result
@router.post("/api/pools", response_model=IPAMPoolResponse)
async def create_pool(
pool_data: IPAMPoolCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
"""Create a new IP pool"""
# Validate network exists
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == pool_data.network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
# Validate range
ipam = IPAMService(db)
is_valid, error = ipam.validate_pool_range(
pool_data.network_id,
pool_data.range_start,
pool_data.range_end
)
if not is_valid:
raise HTTPException(status_code=400, detail=error)
pool = IPAMPool(**pool_data.model_dump())
db.add(pool)
db.commit()
db.refresh(pool)
logger.info(f"User {current_user.username} created pool {pool.name} in network {network.name}")
return pool
@router.put("/api/pools/{pool_id}", response_model=IPAMPoolResponse)
async def update_pool(
pool_id: int,
pool_data: IPAMPoolUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
"""Update an IP pool"""
pool = db.query(IPAMPool).filter(IPAMPool.id == pool_id).first()
if not pool:
raise HTTPException(status_code=404, detail="Pool not found")
# Validate new range if provided
update_data = pool_data.model_dump(exclude_unset=True)
if 'range_start' in update_data or 'range_end' in update_data:
ipam = IPAMService(db)
is_valid, error = ipam.validate_pool_range(
pool.network_id,
update_data.get('range_start', pool.range_start),
update_data.get('range_end', pool.range_end),
exclude_pool_id=pool_id
)
if not is_valid:
raise HTTPException(status_code=400, detail=error)
for field, value in update_data.items():
setattr(pool, field, value)
db.commit()
db.refresh(pool)
logger.info(f"User {current_user.username} updated pool {pool.name}")
return pool
@router.delete("/api/pools/{pool_id}")
async def delete_pool(
pool_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
"""Delete an IP pool"""
pool = db.query(IPAMPool).filter(IPAMPool.id == pool_id).first()
if not pool:
raise HTTPException(status_code=404, detail="Pool not found")
# Update allocations to remove pool reference
db.query(IPAMAllocation).filter(IPAMAllocation.pool_id == pool_id).update({IPAMAllocation.pool_id: None})
db.delete(pool)
db.commit()
logger.info(f"User {current_user.username} deleted pool {pool.name}")
return {"message": f"Pool {pool.name} deleted"}
# ==================== Allocation API ====================
@router.get("/api/allocations", response_model=List[IPAMAllocationResponse])
async def get_allocations(
network_id: Optional[int] = None,
pool_id: Optional[int] = None,
status: Optional[str] = None,
resource_type: Optional[str] = None,
search: Optional[str] = None,
limit: int = Query(default=100, le=1000),
offset: int = 0,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
):
"""Get IP allocations with filtering"""
query = db.query(IPAMAllocation)
if network_id:
query = query.filter(IPAMAllocation.network_id == network_id)
if pool_id:
query = query.filter(IPAMAllocation.pool_id == pool_id)
if status:
query = query.filter(IPAMAllocation.status == status)
if resource_type:
query = query.filter(IPAMAllocation.resource_type == resource_type)
if search:
search_filter = f"%{search}%"
query = query.filter(
(IPAMAllocation.ip_address.like(search_filter)) |
(IPAMAllocation.resource_name.like(search_filter)) |
(IPAMAllocation.hostname.like(search_filter))
)
allocations = query.order_by(IPAMAllocation.ip_address).offset(offset).limit(limit).all()
# Add network/pool names
networks = {n.id: n.name for n in db.query(IPAMNetwork).all()}
pools = {p.id: p.name for p in db.query(IPAMPool).all()}
result = []
for alloc in allocations:
alloc_dict = {
**alloc.__dict__,
'network_name': networks.get(alloc.network_id),
'pool_name': pools.get(alloc.pool_id) if alloc.pool_id else None
}
result.append(alloc_dict)
return result
@router.get("/api/allocations/{allocation_id}", response_model=IPAMAllocationResponse)
async def get_allocation(
allocation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
):
"""Get a specific allocation"""
allocation = db.query(IPAMAllocation).filter(IPAMAllocation.id == allocation_id).first()
if not allocation:
raise HTTPException(status_code=404, detail="Allocation not found")
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == allocation.network_id).first()
pool = db.query(IPAMPool).filter(IPAMPool.id == allocation.pool_id).first() if allocation.pool_id else None
return {
**allocation.__dict__,
'network_name': network.name if network else None,
'pool_name': pool.name if pool else None
}
@router.post("/api/allocations", response_model=IPAMAllocationResponse)
async def create_allocation(
alloc_data: IPAMAllocationCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
"""Manually create an IP allocation"""
ipam = IPAMService(db)
allocation, error = ipam.allocate_ip(
ip_address=alloc_data.ip_address,
network_id=alloc_data.network_id,
pool_id=alloc_data.pool_id,
resource_type=alloc_data.resource_type,
resource_id=alloc_data.resource_id,
resource_name=alloc_data.resource_name,
hostname=alloc_data.hostname,
fqdn=alloc_data.fqdn,
mac_address=alloc_data.mac_address,
proxmox_server_id=alloc_data.proxmox_server_id,
proxmox_vmid=alloc_data.proxmox_vmid,
proxmox_node=alloc_data.proxmox_node,
allocation_type=alloc_data.allocation_type,
status=alloc_data.status,
allocated_by=current_user.username,
notes=alloc_data.notes
)
if error:
raise HTTPException(status_code=400, detail=error)
return allocation
@router.post("/api/allocations/auto", response_model=IPAMAllocationResponse)
async def auto_allocate_ip(
request: IPAMAutoAllocateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
"""Automatically allocate the next available IP"""
ipam = IPAMService(db)
allocation, error = ipam.auto_allocate_ip(
network_id=request.network_id,
pool_id=request.pool_id,
resource_type=request.resource_type,
resource_id=request.resource_id,
resource_name=request.resource_name,
hostname=request.hostname,
allocated_by=current_user.username,
notes=request.notes
)
if error:
raise HTTPException(status_code=400, detail=error)
return allocation
@router.put("/api/allocations/{allocation_id}", response_model=IPAMAllocationResponse)
async def update_allocation(
allocation_id: int,
alloc_data: IPAMAllocationUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
"""Update an allocation"""
allocation = db.query(IPAMAllocation).filter(IPAMAllocation.id == allocation_id).first()
if not allocation:
raise HTTPException(status_code=404, detail="Allocation not found")
ipam = IPAMService(db)
updated, error = ipam.update_allocation(
allocation.ip_address,
updated_by=current_user.username,
**alloc_data.model_dump(exclude_unset=True)
)
if error:
raise HTTPException(status_code=400, detail=error)
return updated
@router.delete("/api/allocations/{allocation_id}")
async def delete_allocation(
allocation_id: int,
reason: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
"""Release/delete an IP allocation"""
allocation = db.query(IPAMAllocation).filter(IPAMAllocation.id == allocation_id).first()
if not allocation:
raise HTTPException(status_code=404, detail="Allocation not found")
ipam = IPAMService(db)
success, error = ipam.release_ip(
allocation.ip_address,
released_by=current_user.username,
reason=reason
)
if not success:
raise HTTPException(status_code=400, detail=error)
return {"message": f"IP {allocation.ip_address} released"}
@router.get("/api/allocations/next-available/{network_id}")
async def get_next_available_ip(
network_id: int,
pool_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
):
"""Get the next available IP in a network (without allocating)"""
ipam = IPAMService(db)
ip = ipam.get_next_available_ip(network_id, pool_id)
if not ip:
raise HTTPException(status_code=404, detail="No available IP addresses")
return {"next_available_ip": ip}
# ==================== History API ====================
@router.get("/api/history", response_model=List[IPAMHistoryResponse])
async def get_history(
network_id: Optional[int] = None,
ip_address: Optional[str] = None,
action: Optional[str] = None,
limit: int = Query(default=100, le=500),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
):
"""Get IPAM history"""
query = db.query(IPAMHistory)
if network_id:
query = query.filter(IPAMHistory.network_id == network_id)
if ip_address:
query = query.filter(IPAMHistory.ip_address == ip_address)
if action:
query = query.filter(IPAMHistory.action == action)
return query.order_by(IPAMHistory.performed_at.desc()).limit(limit).all()
@router.get("/api/history/ip/{ip_address}", response_model=List[IPAMHistoryResponse])
async def get_ip_history(
ip_address: str,
limit: int = Query(default=50, le=200),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
):
"""Get history for a specific IP address"""
ipam = IPAMService(db)
return ipam.get_ip_history(ip_address, limit)
# ==================== Utility API ====================
@router.get("/api/conflicts")
async def detect_conflicts(
network_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
):
"""Detect IP address conflicts"""
ipam = IPAMService(db)
conflicts = ipam.detect_conflicts(network_id)
return {"conflicts": conflicts, "count": len(conflicts)}
@router.get("/api/summary")
async def get_ipam_summary(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.view"))
):
"""Get overall IPAM summary"""
networks_count = db.query(IPAMNetwork).filter(IPAMNetwork.is_active == True).count()
pools_count = db.query(IPAMPool).filter(IPAMPool.is_active == True).count()
total_allocated = db.query(IPAMAllocation).filter(IPAMAllocation.status == 'allocated').count()
total_reserved = db.query(IPAMAllocation).filter(IPAMAllocation.status == 'reserved').count()
# Count by resource type
vm_count = db.query(IPAMAllocation).filter(IPAMAllocation.resource_type == 'vm').count()
lxc_count = db.query(IPAMAllocation).filter(IPAMAllocation.resource_type == 'lxc').count()
physical_count = db.query(IPAMAllocation).filter(IPAMAllocation.resource_type == 'physical').count()
# Recent activity
recent_allocations = db.query(IPAMHistory).filter(
IPAMHistory.action == 'allocated'
).order_by(IPAMHistory.performed_at.desc()).limit(5).all()
return {
'networks_count': networks_count,
'pools_count': pools_count,
'total_allocated': total_allocated,
'total_reserved': total_reserved,
'vm_count': vm_count,
'lxc_count': lxc_count,
'physical_count': physical_count,
'recent_allocations': [
{
'ip': h.ip_address,
'resource': h.resource_name,
'by': h.performed_by,
'at': h.performed_at.isoformat() if h.performed_at else None
} for h in recent_allocations
]
}
# ==================== Maintenance API ====================
@router.get("/api/orphans")
async def get_orphan_allocations(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
"""
Get orphan IPAM allocations - IPs allocated to VMs that no longer exist.
"""
orphans = []
# 1. Allocations with proxmox_vmid but VM doesn't exist or is deleted
allocations_with_vmid = db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_vmid.isnot(None),
IPAMAllocation.proxmox_server_id.isnot(None),
IPAMAllocation.status.in_(['allocated', 'reserved'])
).all()
for alloc in allocations_with_vmid:
vm = db.query(VMInstance).filter(
VMInstance.vmid == alloc.proxmox_vmid,
VMInstance.server_id == alloc.proxmox_server_id,
VMInstance.deleted_at.is_(None)
).first()
if not vm:
orphans.append({
'id': alloc.id,
'ip_address': alloc.ip_address,
'resource_name': alloc.resource_name,
'resource_type': alloc.resource_type,
'proxmox_vmid': alloc.proxmox_vmid,
'proxmox_server_id': alloc.proxmox_server_id,
'reason': 'VM/LXC not found or deleted'
})
# 2. Allocations to non-existent servers
allocations_with_server = db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_server_id.isnot(None),
IPAMAllocation.status.in_(['allocated', 'reserved'])
).all()
server_ids = {a.proxmox_server_id for a in allocations_with_server}
existing_servers = {s.id for s in db.query(ProxmoxServer.id).filter(ProxmoxServer.id.in_(server_ids)).all()}
for alloc in allocations_with_server:
if alloc.proxmox_server_id not in existing_servers:
# Check if not already in orphans
if not any(o['id'] == alloc.id for o in orphans):
orphans.append({
'id': alloc.id,
'ip_address': alloc.ip_address,
'resource_name': alloc.resource_name,
'resource_type': alloc.resource_type,
'proxmox_vmid': alloc.proxmox_vmid,
'proxmox_server_id': alloc.proxmox_server_id,
'reason': f'Server {alloc.proxmox_server_id} not found'
})
return {
'orphans': orphans,
'count': len(orphans)
}
@router.post("/api/cleanup-orphans")
async def cleanup_orphan_allocations(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
"""
Clean up orphan IPAM allocations - release IPs for VMs that no longer exist.
"""
ipam = IPAMService(db)
released = []
errors = []
# Get orphans
orphans_result = await get_orphan_allocations(db, current_user)
orphans = orphans_result['orphans']
for orphan in orphans:
try:
success, error = ipam.release_ip(
ip_address=orphan['ip_address'],
released_by=current_user.username,
reason=f"Orphan cleanup: {orphan['reason']}"
)
if success:
released.append(orphan['ip_address'])
logger.info(f"Released orphan IP {orphan['ip_address']} by {current_user.username}")
else:
errors.append({'ip': orphan['ip_address'], 'error': error})
except Exception as e:
errors.append({'ip': orphan['ip_address'], 'error': str(e)})
return {
'released': released,
'released_count': len(released),
'errors': errors,
'error_count': len(errors)
}
@router.get("/api/unlinked")
async def get_unlinked_allocations(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
"""
Get IPAM allocations without proxmox_vmid that can be linked to existing VMs by name.
"""
unlinked = []
# Allocations without proxmox_vmid
allocations = db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_vmid.is_(None),
IPAMAllocation.status.in_(['allocated', 'reserved'])
).all()
for alloc in allocations:
# Try to find matching VM by name
vm = db.query(VMInstance).filter(
VMInstance.name.ilike(alloc.resource_name),
VMInstance.deleted_at.is_(None)
).first()
unlinked.append({
'id': alloc.id,
'ip_address': alloc.ip_address,
'resource_name': alloc.resource_name,
'resource_type': alloc.resource_type,
'can_link': vm is not None,
'suggested_vmid': vm.vmid if vm else None,
'suggested_server_id': vm.server_id if vm else None,
'suggested_server_name': None # Will be filled below
})
if vm:
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == vm.server_id).first()
unlinked[-1]['suggested_server_name'] = server.name if server else None
return {
'unlinked': unlinked,
'count': len(unlinked),
'linkable_count': sum(1 for u in unlinked if u['can_link'])
}
@router.post("/api/link-allocations")
async def link_allocations_to_vms(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam.manage"))
):
"""
Link IPAM allocations to real VMs by matching resource_name.
"""
linked = []
not_found = []
# Get unlinked allocations
allocations = db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_vmid.is_(None),
IPAMAllocation.status.in_(['allocated', 'reserved'])
).all()
for alloc in allocations:
# Find VM by name (case-insensitive)
vm = db.query(VMInstance).filter(
VMInstance.name.ilike(alloc.resource_name),
VMInstance.deleted_at.is_(None)
).first()
if vm:
# Update allocation with VM info
alloc.proxmox_vmid = vm.vmid
alloc.proxmox_server_id = vm.server_id
alloc.resource_id = vm.vmid
linked.append({
'ip_address': alloc.ip_address,
'resource_name': alloc.resource_name,
'vmid': vm.vmid,
'server_id': vm.server_id
})
logger.info(f"Linked IP {alloc.ip_address} to VM {vm.vmid} on server {vm.server_id}")
else:
not_found.append({
'ip_address': alloc.ip_address,
'resource_name': alloc.resource_name
})
if linked:
db.commit()
return {
'linked': linked,
'linked_count': len(linked),
'not_found': not_found,
'not_found_count': len(not_found)
}

226
backend/app/api/logs.py Normal file
View File

@@ -0,0 +1,226 @@
"""
Logs API endpoints
View and manage audit logs
"""
from datetime import datetime, timedelta
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from pydantic import BaseModel
from ..db import get_db
from ..auth import get_current_user, PermissionChecker
from ..models import User, AuditLog
from ..logging_service import LoggingService
from ..template_helpers import add_i18n_context
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# ==================== Schemas ====================
class LogResponse(BaseModel):
id: int
level: str
category: str
action: str
message: str
username: Optional[str] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
resource_type: Optional[str] = None
resource_id: Optional[str] = None
resource_name: Optional[str] = None
details: Optional[dict] = None
request_method: Optional[str] = None
request_path: Optional[str] = None
response_status: Optional[int] = None
duration_ms: Optional[int] = None
success: bool
error_message: Optional[str] = None
created_at: datetime
# Enhanced logging fields
request_id: Optional[str] = None
session_id: Optional[str] = None
geo_location: Optional[str] = None
server_id: Optional[int] = None
server_name: Optional[str] = None
node_name: Optional[str] = None
request_body: Optional[dict] = None
response_body: Optional[dict] = None
query_params: Optional[str] = None
error_traceback: Optional[str] = None
class Config:
from_attributes = True
class LogsListResponse(BaseModel):
logs: List[LogResponse]
total: int
page: int
limit: int
pages: int
class LogStatsResponse(BaseModel):
period_hours: int
total: int
by_level: dict
by_category: dict
errors_count: int
failed_logins: int
recent_errors: List[dict]
# ==================== HTML Endpoints ====================
@router.get("/", response_class=HTMLResponse)
async def logs_page(request: Request):
"""Render logs page"""
from ..i18n import t
lang = request.cookies.get("language", "ru")
context = {
"request": request,
"title": t("nav_logs", lang),
"page_title": f"📋 {t('system_logs', lang)}"
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("logs.html", context)
# ==================== API Endpoints ====================
@router.get("/api/logs", response_model=LogsListResponse)
async def get_logs(
level: Optional[str] = Query(None, description="Filter by log level"),
category: Optional[str] = Query(None, description="Filter by category"),
username: Optional[str] = Query(None, description="Filter by username"),
ip: Optional[str] = Query(None, description="Filter by IP address"),
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
resource_id: Optional[str] = Query(None, description="Filter by resource ID"),
date_from: Optional[str] = Query(None, description="Filter from date (YYYY-MM-DD)"),
date_to: Optional[str] = Query(None, description="Filter to date (YYYY-MM-DD)"),
search: Optional[str] = Query(None, description="Search in message"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(50, ge=1, le=500, description="Items per page"),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("logs.view"))
):
"""Get logs with filtering and pagination"""
# Parse dates
parsed_date_from = None
parsed_date_to = None
if date_from:
try:
parsed_date_from = datetime.strptime(date_from, "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date_from format. Use YYYY-MM-DD")
if date_to:
try:
parsed_date_to = datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1) # Include the whole day
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date_to format. Use YYYY-MM-DD")
# Calculate offset
offset = (page - 1) * limit
# Get logs
logs, total = LoggingService.get_logs(
db=db,
level=level,
category=category,
username=username,
ip_address=ip,
resource_type=resource_type,
resource_id=resource_id,
date_from=parsed_date_from,
date_to=parsed_date_to,
search=search,
limit=limit,
offset=offset
)
# Calculate total pages
pages = (total + limit - 1) // limit if total > 0 else 1
return LogsListResponse(
logs=[LogResponse.model_validate(log) for log in logs],
total=total,
page=page,
limit=limit,
pages=pages
)
@router.get("/api/stats", response_model=LogStatsResponse)
async def get_log_stats(
hours: int = Query(24, ge=1, le=720, description="Period in hours"),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("logs.view"))
):
"""Get log statistics for the specified period"""
stats = LoggingService.get_stats(db, hours=hours)
return LogStatsResponse(**stats)
@router.delete("/api/logs")
async def cleanup_logs(
days: int = Query(30, ge=1, le=365, description="Delete logs older than this many days"),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("logs.delete"))
):
"""Delete old logs (admin only)"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only admins can delete logs")
deleted = LoggingService.cleanup_old_logs(db, days=days)
# Log this action
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.SYSTEM,
action="logs_cleanup",
message=f"Удалено {deleted} логов старше {days} дней",
username=current_user.username
)
return {"deleted": deleted, "message": f"Удалено {deleted} логов старше {days} дней"}
@router.get("/api/levels")
async def get_log_levels():
"""Get available log levels"""
return {
"levels": [
{"value": "debug", "label": "Debug", "color": "#6c757d"},
{"value": "info", "label": "Info", "color": "#0dcaf0"},
{"value": "warning", "label": "Warning", "color": "#ffc107"},
{"value": "error", "label": "Error", "color": "#dc3545"},
{"value": "critical", "label": "Critical", "color": "#dc3545"}
]
}
@router.get("/api/categories")
async def get_log_categories():
"""Get available log categories"""
return {
"categories": [
{"value": "auth", "label": "🔐 Авторизация"},
{"value": "proxmox", "label": "🖥️ Proxmox"},
{"value": "ipam", "label": "🌐 IPAM"},
{"value": "system", "label": "⚙️ Система"},
{"value": "api", "label": "🔌 API"},
{"value": "docker", "label": "🐳 Docker"}
]
}

View File

@@ -0,0 +1,253 @@
"""
API routes for notifications
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..db import get_db
from ..auth import get_current_user, PermissionChecker
from ..models import User
from ..services.notification_service import NotificationService
from ..schemas import (
NotificationResponse,
NotificationListResponse,
NotificationUpdate,
NotificationPreferenceResponse,
NotificationPreferenceUpdate
)
router = APIRouter(prefix="/api/notifications", tags=["notifications"])
@router.get("/", response_model=NotificationListResponse)
async def get_notifications(
unread_only: bool = Query(False, description="Only unread notifications"),
level: Optional[str] = Query(None, description="Filter by level: critical, warning, info, success"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
current_user: User = Depends(PermissionChecker("notifications.manage")),
db: Session = Depends(get_db)
):
"""
Get user notifications with filters
- **unread_only**: Return only unread notifications
- **level**: Filter by notification level
- **limit**: Maximum number of notifications (1-100)
- **offset**: Offset for pagination
"""
notifications, total = NotificationService.get_user_notifications(
db,
user_id=current_user.id,
unread_only=unread_only,
level=level,
limit=limit,
offset=offset
)
unread_count = NotificationService.get_unread_count(db, current_user.id)
return {
"total": total,
"unread_count": unread_count,
"notifications": [n.to_dict() for n in notifications]
}
@router.get("/unread-count")
async def get_unread_count(
current_user: User = Depends(PermissionChecker("notifications.manage")),
db: Session = Depends(get_db)
):
"""Get count of unread notifications"""
count = NotificationService.get_unread_count(db, current_user.id)
return {"count": count}
@router.patch("/{notification_id}/read", response_model=NotificationResponse)
async def mark_notification_as_read(
notification_id: int,
current_user: User = Depends(PermissionChecker("notifications.manage")),
db: Session = Depends(get_db)
):
"""Mark notification as read"""
notification = NotificationService.mark_as_read(
db,
notification_id=notification_id,
user_id=current_user.id
)
if not notification:
raise HTTPException(status_code=404, detail="Notification not found")
return notification.to_dict()
@router.post("/mark-all-read")
async def mark_all_notifications_as_read(
current_user: User = Depends(PermissionChecker("notifications.manage")),
db: Session = Depends(get_db)
):
"""Mark all notifications as read"""
count = NotificationService.mark_all_as_read(db, current_user.id)
return {"message": f"{count} notifications marked as read"}
@router.delete("/{notification_id}")
async def delete_notification(
notification_id: int,
current_user: User = Depends(PermissionChecker("notifications.manage")),
db: Session = Depends(get_db)
):
"""Delete a notification"""
success = NotificationService.delete_notification(
db,
notification_id=notification_id,
user_id=current_user.id
)
if not success:
raise HTTPException(status_code=404, detail="Notification not found")
return {"message": "Notification deleted"}
@router.delete("/read/all")
async def delete_all_read_notifications(
current_user: User = Depends(PermissionChecker("notifications.manage")),
db: Session = Depends(get_db)
):
"""Delete all read notifications"""
count = NotificationService.delete_all_read(db, current_user.id)
return {"message": f"{count} notifications deleted"}
# Preferences endpoints
@router.get("/preferences", response_model=NotificationPreferenceResponse)
async def get_notification_preferences(
current_user: User = Depends(PermissionChecker("notifications.manage")),
db: Session = Depends(get_db)
):
"""Get user notification preferences"""
prefs = NotificationService.get_user_preferences(db, current_user.id)
return prefs.to_dict()
@router.put("/preferences", response_model=NotificationPreferenceResponse)
async def update_notification_preferences(
prefs_data: NotificationPreferenceUpdate,
current_user: User = Depends(PermissionChecker("notifications.manage")),
db: Session = Depends(get_db)
):
"""Update user notification preferences"""
prefs = NotificationService.update_user_preferences(
db,
user_id=current_user.id,
prefs_data=prefs_data
)
return prefs.to_dict()
# ============================================
# Channel management endpoints
# ============================================
@router.get("/channels/status")
async def get_channels_status(
current_user: User = Depends(PermissionChecker("notifications.manage")),
db: Session = Depends(get_db)
):
"""
Get status of all notification channels
Returns configuration and status for:
- Email (SMTP)
- Telegram
- In-app notifications
"""
status = await NotificationService.get_channels_status(db, current_user.id)
return status
@router.post("/test")
async def send_test_notification(
channel: str = Query("all", description="Channel to test: all, email, telegram, inapp"),
current_user: User = Depends(PermissionChecker("notifications.manage")),
db: Session = Depends(get_db)
):
"""
Send a test notification through specified channel(s)
- **channel**: Which channel to test (all, email, telegram, inapp)
"""
if channel not in ["all", "email", "telegram", "inapp"]:
raise HTTPException(status_code=400, detail="Invalid channel. Use: all, email, telegram, inapp")
results = await NotificationService.send_test_notification(db, current_user.id, channel)
return results
@router.post("/telegram/verify")
async def verify_telegram_chat(
chat_id: str = Query(..., description="Telegram chat ID to verify"),
current_user: User = Depends(PermissionChecker("notifications.manage")),
db: Session = Depends(get_db)
):
"""
Verify Telegram chat ID and save to preferences
Steps to get your chat_id:
1. Start a conversation with @userinfobot in Telegram
2. It will tell you your chat ID
3. Make sure you've sent /start to your notification bot first
"""
from ..services.notification_channels import get_telegram_channel
tg_ch = get_telegram_channel()
if not tg_ch.is_configured():
raise HTTPException(
status_code=400,
detail="Telegram бот не настроен. Добавьте токен в Настройки → Уведомления."
)
# Try to send test message
success = await tg_ch.verify_chat_id(chat_id)
if not success:
raise HTTPException(
status_code=400,
detail="Failed to send message to this chat_id. Make sure you've started the bot with /start"
)
# Save chat_id to preferences
prefs = NotificationService.get_user_preferences(db, current_user.id)
prefs.telegram_chat_id = chat_id
prefs.telegram_enabled = True
db.commit()
return {
"success": True,
"message": "Telegram verified and enabled!",
"chat_id": chat_id
}
@router.get("/telegram/bot-info")
async def get_telegram_bot_info(
current_user: User = Depends(PermissionChecker("notifications.manage"))
):
"""Get information about the configured Telegram bot"""
from ..services.notification_channels import get_telegram_channel
tg_ch = get_telegram_channel()
if not tg_ch.is_configured():
return {"configured": False, "bot_info": None}
bot_info = await tg_ch.get_bot_info()
return {
"configured": True,
"bot_info": bot_info
}

5418
backend/app/api/proxmox.py Normal file

File diff suppressed because it is too large Load Diff

754
backend/app/api/settings.py Normal file
View File

@@ -0,0 +1,754 @@
"""
Settings API endpoints
User account settings and panel configuration
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
from fastapi.responses import HTMLResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from ..db import get_db
from ..models import User, PanelSettings
from ..auth import get_current_user, get_current_user_optional, get_password_hash, verify_password, PermissionChecker
from ..logging_service import LoggingService
from ..template_helpers import add_i18n_context
from ..i18n import I18nService, t
from ..services.logo_service import LogoService
from ..services.update_service import (
get_current_version,
check_for_updates,
perform_update,
get_update_status,
reset_update_status
)
def get_local_time() -> str:
"""Get current local time formatted string"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# ==================== Helper Functions ====================
def get_setting(db: Session, key: str, default: str = None) -> str:
"""Get setting value from database"""
setting = db.query(PanelSettings).filter(PanelSettings.key == key).first()
if setting:
return setting.value
return default
def set_setting(db: Session, key: str, value: str, description: str = None):
"""Set setting value in database"""
setting = db.query(PanelSettings).filter(PanelSettings.key == key).first()
if setting:
setting.value = value
if description:
setting.description = description
else:
setting = PanelSettings(key=key, value=value, description=description)
db.add(setting)
db.commit()
# ==================== Schemas ====================
class UpdateProfileRequest(BaseModel):
full_name: Optional[str] = Field(None, max_length=100)
email: Optional[EmailStr] = None
class ChangePasswordRequest(BaseModel):
current_password: str = Field(..., min_length=1)
new_password: str = Field(..., min_length=6)
confirm_password: str = Field(..., min_length=6)
class PanelSettingsResponse(BaseModel):
panel_name: str
refresh_interval: int # seconds
log_retention_days: int
language: str # ru or en
class UpdatePanelSettingsRequest(BaseModel):
refresh_interval: Optional[int] = Field(None, ge=1, le=60)
log_retention_days: Optional[int] = Field(None, ge=1, le=365)
language: Optional[str] = Field(None, pattern='^(ru|en)$')
# ==================== Logo Protection ====================
@router.get("/api/logo.png")
async def get_protected_logo():
"""
Get protected logo image.
Returns the verified original logo or a fallback if tampered.
"""
logo_bytes = LogoService.get_logo_bytes()
return Response(
content=logo_bytes,
media_type="image/png",
headers={
"Cache-Control": "public, max-age=3600",
"X-Logo-Protected": "true"
}
)
# ==================== HTML Pages ====================
@router.get("/", response_class=HTMLResponse)
async def settings_page(
request: Request,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional)
):
"""Render settings page"""
from ..template_helpers import get_language
lang = get_language(request)
context = {
"request": request,
"title": t("nav_settings", lang),
"page_title": f"⚙️ {t('nav_settings', lang)}",
"current_user": current_user
}
# Add i18n context
context = add_i18n_context(request, context)
return templates.TemplateResponse("settings.html", context)
# ==================== User Account Settings ====================
@router.get("/api/profile")
async def get_profile(
current_user: User = Depends(get_current_user)
):
"""Get current user profile"""
return {
"id": current_user.id,
"username": current_user.username,
"email": current_user.email,
"full_name": current_user.full_name,
"is_admin": current_user.is_admin,
"is_active": current_user.is_active,
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
"last_login": current_user.last_login.isoformat() if current_user.last_login else None
}
@router.put("/api/profile")
async def update_profile(
data: UpdateProfileRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update user profile"""
updated_fields = []
if data.full_name is not None:
current_user.full_name = data.full_name
updated_fields.append("full_name")
if data.email is not None:
# Check if email is already taken by another user
existing = db.query(User).filter(
User.email == data.email,
User.id != current_user.id
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email уже используется другим пользователем"
)
current_user.email = data.email
updated_fields.append("email")
if updated_fields:
db.commit()
db.refresh(current_user)
# Log profile update
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="profile_update",
message=f"Пользователь {current_user.username} обновил профиль: {', '.join(updated_fields)}",
username=current_user.username,
user_id=current_user.id
)
return {
"message": "Профиль обновлён",
"updated_fields": updated_fields
}
@router.post("/api/change-password")
async def change_password(
data: ChangePasswordRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Change user password"""
# Verify current password
if not verify_password(data.current_password, current_user.hashed_password):
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.AUTH,
action="password_change_failed",
message=f"Неудачная попытка смены пароля (неверный текущий пароль)",
username=current_user.username,
user_id=current_user.id,
success=False
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Неверный текущий пароль"
)
# Check new password confirmation
if data.new_password != data.confirm_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Новые пароли не совпадают"
)
# Update password
current_user.hashed_password = get_password_hash(data.new_password)
db.commit()
# Log password change
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="password_change",
message=f"Пользователь {current_user.username} изменил пароль",
username=current_user.username,
user_id=current_user.id,
success=True
)
return {"message": "Пароль успешно изменён"}
# ==================== Panel Settings ====================
@router.get("/api/panel", response_model=PanelSettingsResponse)
async def get_panel_settings(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.view"))
):
"""Get panel settings"""
from ..config import settings
# Get settings from database or use defaults
refresh_interval = get_setting(db, "refresh_interval", "5")
log_retention_days = get_setting(db, "log_retention_days", "30")
language = get_setting(db, "language", "ru")
return {
"panel_name": settings.PANEL_NAME,
"refresh_interval": int(refresh_interval),
"log_retention_days": int(log_retention_days),
"language": language
}
@router.put("/api/panel")
async def update_panel_settings(
data: UpdatePanelSettingsRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel"))
):
"""Update panel settings (requires settings.panel permission)"""
updated_fields = []
# Save settings to database
if data.refresh_interval is not None:
set_setting(db, "refresh_interval", str(data.refresh_interval), "Интервал обновления данных (секунды)")
updated_fields.append(f"refresh_interval={data.refresh_interval}")
if data.log_retention_days is not None:
set_setting(db, "log_retention_days", str(data.log_retention_days), "Срок хранения логов (дни)")
updated_fields.append(f"log_retention_days={data.log_retention_days}")
if data.language is not None:
set_setting(db, "language", data.language, "Язык интерфейса панели")
updated_fields.append(f"language={data.language}")
if updated_fields:
# Log settings change
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.SYSTEM,
action="panel_settings_update",
message=f"Администратор {current_user.username} обновил настройки панели: {', '.join(updated_fields)}",
username=current_user.username,
user_id=current_user.id,
details=data.dict(exclude_none=True)
)
return {
"message": "Настройки панели обновлены",
"updated_fields": updated_fields
}
@router.post("/api/cleanup-logs")
async def cleanup_old_logs(
days: int = 30,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Manually cleanup old logs (admin only)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Только администраторы могут очищать логи"
)
deleted = LoggingService.cleanup_old_logs(db, days=days)
# Log the cleanup action
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.SYSTEM,
action="logs_cleanup",
message=f"Администратор {current_user.username} очистил {deleted} логов старше {days} дней",
username=current_user.username,
user_id=current_user.id,
details={"deleted_count": deleted, "older_than_days": days}
)
return {
"message": f"Удалено {deleted} логов старше {days} дней",
"deleted": deleted
}
# ==================== Translations API ====================
@router.get("/api/translations/{lang}")
async def get_translations(lang: str):
"""Get all translations for specific language"""
if lang not in ["ru", "en"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unsupported language. Use 'ru' or 'en'"
)
return I18nService.get_all(lang)
# ==================== Security Settings API ====================
class SecuritySettingsRequest(BaseModel):
single_session_enabled: Optional[bool] = None
@router.get("/api/security-settings")
async def get_security_settings(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get security settings (admin only)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Только администраторы могут просматривать настройки безопасности"
)
from ..services.security_service import SecurityService
return {
"single_session_enabled": SecurityService.get_setting_bool(db, "single_session_enabled", False)
}
@router.put("/api/security-settings")
async def update_security_settings(
data: SecuritySettingsRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update security settings (admin only)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Только администраторы могут изменять настройки безопасности"
)
from ..services.security_service import SecurityService
updated_fields = []
if data.single_session_enabled is not None:
SecurityService.update_setting(db, "single_session_enabled", str(data.single_session_enabled).lower())
updated_fields.append(f"single_session_enabled={data.single_session_enabled}")
if updated_fields:
# Log settings change
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="security_settings_update",
message=f"Администратор {current_user.username} обновил настройки безопасности: {', '.join(updated_fields)}",
username=current_user.username,
user_id=current_user.id,
details=data.model_dump(exclude_none=True)
)
return {
"message": "Настройки безопасности обновлены",
"updated_fields": updated_fields
}
# ==================== Notification Channel Settings API ====================
class SMTPSettingsRequest(BaseModel):
smtp_host: Optional[str] = Field(None, max_length=255)
smtp_port: Optional[int] = Field(None, ge=1, le=65535)
smtp_user: Optional[str] = Field(None, max_length=255)
smtp_password: Optional[str] = Field(None, max_length=255)
smtp_from: Optional[str] = Field(None, max_length=255)
smtp_tls: Optional[bool] = None
class TelegramSettingsRequest(BaseModel):
telegram_bot_token: Optional[str] = Field(None, max_length=255)
@router.get("/api/notification-channels")
async def get_notification_channel_settings(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get notification channel settings (admin only)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Только администраторы могут просматривать настройки уведомлений"
)
return {
"smtp": {
"host": get_setting(db, "smtp_host", ""),
"port": int(get_setting(db, "smtp_port", "587") or "587"),
"user": get_setting(db, "smtp_user", ""),
"password": "***" if get_setting(db, "smtp_password") else "",
"from": get_setting(db, "smtp_from", ""),
"tls": get_setting(db, "smtp_tls", "true").lower() == "true"
},
"telegram": {
"bot_token": "***" if get_setting(db, "telegram_bot_token") else "",
"configured": bool(get_setting(db, "telegram_bot_token"))
}
}
@router.put("/api/notification-channels/smtp")
async def update_smtp_settings(
data: SMTPSettingsRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update SMTP settings (admin only)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Только администраторы могут изменять настройки SMTP"
)
updated_fields = []
if data.smtp_host is not None:
set_setting(db, "smtp_host", data.smtp_host, "SMTP сервер")
updated_fields.append("smtp_host")
if data.smtp_port is not None:
set_setting(db, "smtp_port", str(data.smtp_port), "SMTP порт")
updated_fields.append("smtp_port")
if data.smtp_user is not None:
set_setting(db, "smtp_user", data.smtp_user, "SMTP пользователь")
updated_fields.append("smtp_user")
if data.smtp_password is not None:
set_setting(db, "smtp_password", data.smtp_password, "SMTP пароль")
updated_fields.append("smtp_password")
if data.smtp_from is not None:
set_setting(db, "smtp_from", data.smtp_from, "Email отправителя")
updated_fields.append("smtp_from")
if data.smtp_tls is not None:
set_setting(db, "smtp_tls", str(data.smtp_tls).lower(), "Использовать TLS")
updated_fields.append("smtp_tls")
if updated_fields:
# Reload notification channels
from ..services.notification_channels import reload_notification_channels
reload_notification_channels()
# Log settings change
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.SYSTEM,
action="smtp_settings_update",
message=f"Администратор {current_user.username} обновил настройки SMTP: {', '.join(updated_fields)}",
username=current_user.username,
user_id=current_user.id
)
return {
"message": "Настройки SMTP обновлены",
"updated_fields": updated_fields
}
@router.put("/api/notification-channels/telegram")
async def update_telegram_settings(
data: TelegramSettingsRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update Telegram bot settings (admin only)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Только администраторы могут изменять настройки Telegram"
)
updated_fields = []
if data.telegram_bot_token is not None:
set_setting(db, "telegram_bot_token", data.telegram_bot_token, "Токен Telegram бота")
updated_fields.append("telegram_bot_token")
if updated_fields:
# Reload notification channels
from ..services.notification_channels import reload_notification_channels
reload_notification_channels()
# Log settings change
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.SYSTEM,
action="telegram_settings_update",
message=f"Администратор {current_user.username} обновил настройки Telegram",
username=current_user.username,
user_id=current_user.id
)
return {
"message": "Настройки Telegram обновлены",
"updated_fields": updated_fields
}
@router.post("/api/notification-channels/smtp/test")
async def test_smtp_connection(
test_email: str = Query(..., description="Email для отправки тестового письма"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Test SMTP connection by sending a test email (admin only)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Только администраторы могут тестировать SMTP"
)
from ..services.notification_channels import get_email_channel
email_channel = get_email_channel()
if not email_channel.is_configured():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="SMTP не настроен. Заполните все необходимые поля."
)
success = await email_channel.send(
to_email=test_email,
subject="🔧 Тестовое письмо - PVEmanager",
body=f"Тестовое письмо от PVEmanager.\n\nВремя: {get_local_time()}\nОтправлено пользователем: {current_user.username}",
html_body=f"""
<div style="font-family: Arial, sans-serif; padding: 20px; background: #1a1a2e; color: #fff;">
<h2 style="color: #667eea;">🔧 Тестовое письмо</h2>
<p>SMTP настройки работают корректно!</p>
<p>Время: {get_local_time()}</p>
<p>Отправлено пользователем: {current_user.username}</p>
<hr style="border-color: #333;">
<p style="color: #888; font-size: 12px;">PVEmanager</p>
</div>
"""
)
if success:
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.SYSTEM,
action="smtp_test",
message=f"Администратор {current_user.username} отправил тестовое письмо на {test_email}",
username=current_user.username,
user_id=current_user.id
)
return {"success": True, "message": f"Тестовое письмо отправлено на {test_email}"}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Не удалось отправить тестовое письмо. Проверьте настройки SMTP."
)
@router.post("/api/notification-channels/telegram/test")
async def test_telegram_bot(
chat_id: str = Query(..., description="Telegram Chat ID для отправки тестового сообщения"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Test Telegram bot by sending a test message (admin only)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Только администраторы могут тестировать Telegram бота"
)
from ..services.notification_channels import get_telegram_channel, get_local_time
tg_channel = get_telegram_channel()
if not tg_channel.is_configured():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Telegram бот не настроен. Укажите токен бота."
)
success = await tg_channel.send(
chat_id=chat_id,
message=f"🔧 <b>Тестовое сообщение</b>\n\nTelegram бот работает корректно!\n\n🕐 Время: {get_local_time()}\n👤 Отправлено: {current_user.username}\n\n🖥️ <i>PVEmanager</i>",
parse_mode="HTML"
)
if success:
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.SYSTEM,
action="telegram_test",
message=f"Администратор {current_user.username} отправил тестовое сообщение в Telegram (chat_id: {chat_id})",
username=current_user.username,
user_id=current_user.id
)
return {"success": True, "message": f"Тестовое сообщение отправлено в Telegram (chat_id: {chat_id})"}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Не удалось отправить тестовое сообщение. Проверьте токен бота и chat_id."
)
# ==================== System Updates ====================
@router.get("/api/version")
async def get_version():
"""Get current panel version"""
return {"version": get_current_version()}
@router.get("/api/updates/check")
async def check_updates(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.manage"))
):
"""Check for available updates (admin only)"""
result = await check_for_updates()
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.SYSTEM,
action="update_check",
message=f"Проверка обновлений: текущая {result['current_version']}, последняя {result.get('latest_version', 'unknown')}",
username=current_user.username,
user_id=current_user.id
)
return result
@router.get("/api/updates/status")
async def updates_status():
"""Get current update status (no auth required for banner check)"""
return get_update_status()
@router.post("/api/updates/perform")
async def perform_system_update(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.manage"))
):
"""Perform system update (admin only)"""
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.SYSTEM,
action="update_start",
message=f"Администратор {current_user.username} запустил обновление системы",
username=current_user.username,
user_id=current_user.id
)
result = await perform_update()
if not result["success"]:
LoggingService.log(
db=db,
level=LoggingService.ERROR,
category=LoggingService.SYSTEM,
action="update_failed",
message=f"Ошибка обновления: {result.get('error')}",
username=current_user.username,
user_id=current_user.id
)
return result
@router.post("/api/updates/reset")
async def reset_update(
current_user: User = Depends(PermissionChecker("settings.manage"))
):
"""Reset update status (admin only)"""
reset_update_status()
return {"success": True, "message": "Update status reset"}

View File

@@ -0,0 +1,926 @@
"""
API endpoints for OS Templates management
Allows creating VM templates, groups, and deploying VMs from templates
"""
from fastapi import APIRouter, Depends, Request, HTTPException, status
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from loguru import logger
from typing import List
from ..db import get_db
from ..models import ProxmoxServer, OSTemplateGroup, OSTemplate, VMInstance, User
from ..schemas import (
OSTemplateGroupCreate, OSTemplateGroupUpdate, OSTemplateGroupResponse,
OSTemplateCreate, OSTemplateUpdate, OSTemplateResponse, OSTemplateWithGroup,
VMDeployRequest, VMDeployResponse
)
from ..proxmox_client import ProxmoxClient
from ..api.proxmox import get_next_vmid, save_vm_instance, get_vm_instance
from ..auth import get_current_user, PermissionChecker
from ..ipam_service import IPAMService
from ..logging_service import LoggingService
from ..template_helpers import add_i18n_context
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# ==================== HTML Pages ====================
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
def templates_page(request: Request, db: Session = Depends(get_db)):
"""Страница управления OS шаблонами"""
from ..i18n import t
lang = request.cookies.get("language", "en")
proxmox_servers = db.query(ProxmoxServer).all()
template_groups = db.query(OSTemplateGroup).order_by(OSTemplateGroup.sort_order).all()
os_templates = db.query(OSTemplate).order_by(OSTemplate.sort_order).all()
context = {
"request": request,
"proxmox_servers": proxmox_servers,
"template_groups": template_groups,
"os_templates": os_templates,
"page_title": t('nav_templates', lang),
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("os_templates.html", context)
# ==================== Template Groups CRUD ====================
@router.get("/api/groups", response_model=List[OSTemplateGroupResponse])
def list_template_groups(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("templates.view"))
):
"""Получить список всех групп шаблонов"""
groups = db.query(OSTemplateGroup).order_by(OSTemplateGroup.sort_order).all()
return groups
@router.post("/api/groups", response_model=OSTemplateGroupResponse, status_code=status.HTTP_201_CREATED)
def create_template_group(
group_data: OSTemplateGroupCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("templates.manage"))
):
"""Создать новую группу шаблонов"""
# Check if group with same name exists
existing = db.query(OSTemplateGroup).filter(OSTemplateGroup.name == group_data.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Группа с именем '{group_data.name}' уже существует"
)
group = OSTemplateGroup(**group_data.model_dump())
db.add(group)
db.commit()
db.refresh(group)
logger.info(f"User {current_user.username} created template group: {group.name}")
return group
@router.get("/api/groups/{group_id}", response_model=OSTemplateGroupResponse)
def get_template_group(
group_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("templates.view"))
):
"""Получить группу шаблонов по ID"""
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
return group
@router.put("/api/groups/{group_id}", response_model=OSTemplateGroupResponse)
def update_template_group(
group_id: int,
group_data: OSTemplateGroupUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("templates.manage"))
):
"""Обновить группу шаблонов"""
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
update_data = group_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(group, field, value)
db.commit()
db.refresh(group)
logger.info(f"User {current_user.username} updated template group: {group.name}")
return group
@router.delete("/api/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_template_group(
group_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("templates.manage"))
):
"""Удалить группу шаблонов"""
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
# Check if there are templates in this group
templates_count = db.query(OSTemplate).filter(OSTemplate.group_id == group_id).count()
if templates_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Невозможно удалить группу: в ней {templates_count} шаблон(ов)"
)
logger.info(f"User {current_user.username} deleted template group: {group.name}")
db.delete(group)
db.commit()
return None
# ==================== OS Templates CRUD ====================
@router.get("/api/templates", response_model=List[OSTemplateWithGroup])
def list_os_templates(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("templates.view")),
group_id: int = None,
server_id: int = None,
active_only: bool = True
):
"""Получить список всех OS шаблонов с информацией о группе"""
query = db.query(OSTemplate)
if group_id:
query = query.filter(OSTemplate.group_id == group_id)
if server_id:
query = query.filter(OSTemplate.server_id == server_id)
if active_only:
query = query.filter(OSTemplate.is_active == True)
os_templates = query.order_by(OSTemplate.sort_order).all()
# Enrich with group and server info
result = []
for template in os_templates:
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == template.group_id).first()
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
template_dict = {
**template.__dict__,
'group_name': group.name if group else None,
'group_icon': group.icon if group else None,
'server_name': server.name if server else None,
}
# Remove SQLAlchemy internal state
template_dict.pop('_sa_instance_state', None)
result.append(template_dict)
return result
@router.post("/api/templates", response_model=OSTemplateResponse, status_code=status.HTTP_201_CREATED)
def create_os_template(
template_data: OSTemplateCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("templates.manage"))
):
"""Создать новый OS шаблон"""
# Validate group exists
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == template_data.group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
# Validate server exists
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template_data.server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
# Check if template with same VMID on same server exists
existing = db.query(OSTemplate).filter(
OSTemplate.server_id == template_data.server_id,
OSTemplate.vmid == template_data.vmid
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Шаблон с VMID {template_data.vmid} уже существует для этого сервера"
)
template = OSTemplate(**template_data.model_dump())
db.add(template)
db.commit()
db.refresh(template)
logger.info(f"User {current_user.username} created OS template: {template.name} (VMID: {template.vmid})")
return template
@router.get("/api/templates/{template_id}", response_model=OSTemplateWithGroup)
def get_os_template(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("templates.view"))
):
"""Получить OS шаблон по ID"""
template = db.query(OSTemplate).filter(OSTemplate.id == template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден")
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == template.group_id).first()
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
template_dict = {
**template.__dict__,
'group_name': group.name if group else None,
'group_icon': group.icon if group else None,
'server_name': server.name if server else None,
}
template_dict.pop('_sa_instance_state', None)
return template_dict
@router.put("/api/templates/{template_id}", response_model=OSTemplateResponse)
def update_os_template(
template_id: int,
template_data: OSTemplateUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("templates.manage"))
):
"""Обновить OS шаблон"""
template = db.query(OSTemplate).filter(OSTemplate.id == template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден")
update_data = template_data.model_dump(exclude_unset=True)
# Validate group if changing
if 'group_id' in update_data:
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == update_data['group_id']).first()
if not group:
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
# Validate server if changing
if 'server_id' in update_data:
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == update_data['server_id']).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
for field, value in update_data.items():
setattr(template, field, value)
db.commit()
db.refresh(template)
logger.info(f"User {current_user.username} updated OS template: {template.name}")
return template
@router.delete("/api/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_os_template(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("templates.manage"))
):
"""Удалить OS шаблон"""
template = db.query(OSTemplate).filter(OSTemplate.id == template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден")
logger.info(f"User {current_user.username} deleted OS template: {template.name} (VMID: {template.vmid})")
db.delete(template)
db.commit()
return None
# ==================== Proxmox Templates Discovery ====================
@router.get("/api/discover/{server_id}")
def discover_proxmox_templates(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("templates.view"))
):
"""
Обнаружить все VM-шаблоны на Proxmox сервере.
Возвращает список шаблонов, которые можно добавить.
"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
try:
if server.use_password:
client = ProxmoxClient(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl
)
else:
client = ProxmoxClient(
host=server.ip_address,
user=server.api_user,
token_name=server.api_token_name,
token_value=server.api_token_value,
verify_ssl=server.verify_ssl
)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Не удалось подключиться к Proxmox серверу")
# Get templates - фильтруем по hostname ноды если это кластер
node_filter = None
if server.hostname and server.hostname != server.ip_address:
node_filter = server.hostname
proxmox_templates = client.get_templates(node=node_filter)
# Get already added templates для ЭТОГО сервера
existing_vmids = set(
t.vmid for t in db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
)
result = []
for tpl in proxmox_templates:
result.append({
'vmid': tpl.get('vmid'),
'name': tpl.get('name', f"Template-{tpl.get('vmid')}"),
'node': tpl.get('node'),
'status': tpl.get('status'),
'maxmem': tpl.get('maxmem', 0),
'maxdisk': tpl.get('maxdisk', 0),
'already_added': tpl.get('vmid') in existing_vmids
})
return JSONResponse(content={
'server_id': server_id,
'server_name': server.name,
'templates': result
})
except Exception as e:
logger.error(f"Error discovering templates on {server.name}: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка обнаружения шаблонов: {str(e)}")
# ==================== Auto-Import Templates ====================
# Маппинг ключевых слов на группы
# Иконки используют CSS классы для адаптации к теме
OS_GROUP_MAPPING = {
'ubuntu': {'name': 'Ubuntu', 'icon': '<i class="fa-brands fa-ubuntu os-icon os-icon-ubuntu"></i>', 'order': 1},
'debian': {'name': 'Debian', 'icon': '<i class="fa-brands fa-debian os-icon os-icon-debian"></i>', 'order': 2},
'centos': {'name': 'CentOS', 'icon': '<i class="fa-brands fa-centos os-icon os-icon-centos"></i>', 'order': 3},
'rocky': {'name': 'Rocky Linux', 'icon': '<span class="os-icon-rocky-svg"></span>', 'order': 4},
'alma': {'name': 'AlmaLinux', 'icon': '<span class="os-icon-alma-svg"></span>', 'order': 5},
'almalinux': {'name': 'AlmaLinux', 'icon': '<span class="os-icon-alma-svg"></span>', 'order': 5},
'fedora': {'name': 'Fedora', 'icon': '<i class="fa-brands fa-fedora os-icon os-icon-fedora"></i>', 'order': 6},
'rhel': {'name': 'RHEL', 'icon': '<i class="fa-brands fa-redhat os-icon os-icon-rhel"></i>', 'order': 7},
'oracle': {'name': 'Oracle Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-oracle"></i>', 'order': 8},
'suse': {'name': 'openSUSE', 'icon': '<i class="fa-brands fa-suse os-icon os-icon-suse"></i>', 'order': 9},
'opensuse': {'name': 'openSUSE', 'icon': '<i class="fa-brands fa-suse os-icon os-icon-suse"></i>', 'order': 9},
'arch': {'name': 'Arch Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-arch"></i>', 'order': 10},
'gentoo': {'name': 'Gentoo', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-gentoo"></i>', 'order': 11},
'alpine': {'name': 'Alpine', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-alpine"></i>', 'order': 12},
'windows': {'name': 'Windows', 'icon': '<i class="fa-brands fa-windows os-icon os-icon-windows"></i>', 'order': 20},
'win': {'name': 'Windows', 'icon': '<i class="fa-brands fa-windows os-icon os-icon-windows"></i>', 'order': 20},
'freebsd': {'name': 'FreeBSD', 'icon': '<i class="fa-brands fa-freebsd os-icon os-icon-freebsd"></i>', 'order': 30},
'openbsd': {'name': 'OpenBSD', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-openbsd"></i>', 'order': 31},
'other': {'name': 'Other', 'icon': '<i class="fa-solid fa-compact-disc os-icon os-icon-other"></i>', 'order': 99},
}
def detect_os_group(template_name: str) -> dict:
"""Определить группу ОС по имени шаблона"""
name_lower = template_name.lower()
for keyword, group_info in OS_GROUP_MAPPING.items():
if keyword in name_lower:
return group_info
return OS_GROUP_MAPPING['other']
@router.post("/api/auto-import/{server_id}")
def auto_import_templates(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("templates.manage"))
):
"""
Автоматический импорт шаблонов с Proxmox сервера.
Автоматически определяет группу ОС и назначает порядок сортировки.
Глобальная сортировка: Ubuntu 0-3, CentOS 4-5 и т.д.
"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
try:
# Подключаемся к Proxmox
if server.use_password:
client = ProxmoxClient(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl
)
else:
client = ProxmoxClient(
host=server.ip_address,
user=server.api_user,
token_name=server.api_token_name,
token_value=server.api_token_value,
verify_ssl=server.verify_ssl
)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Не удалось подключиться к Proxmox серверу")
# Фильтруем по ноде если это кластер
node_filter = None
if server.hostname and server.hostname != server.ip_address:
node_filter = server.hostname
# Получаем шаблоны только с этой ноды
proxmox_templates = client.get_templates(node=node_filter)
# Получаем уже добавленные шаблоны для ЭТОГО сервера
existing_vmids = set(
t.vmid for t in db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
)
# Кеш групп: name -> group
groups_cache = {}
for group in db.query(OSTemplateGroup).all():
groups_cache[group.name] = group
# Группируем шаблоны по ОС
templates_by_group = {} # group_name -> [templates]
for tpl in proxmox_templates:
vmid = tpl.get('vmid')
if vmid in existing_vmids:
continue # Пропускаем уже добавленные
template_name = tpl.get('name', f"Template-{vmid}")
group_info = detect_os_group(template_name)
group_name = group_info['name']
if group_name not in templates_by_group:
templates_by_group[group_name] = {
'info': group_info,
'templates': []
}
templates_by_group[group_name]['templates'].append({
'vmid': vmid,
'name': template_name,
'node': tpl.get('node'),
'maxmem': tpl.get('maxmem', 0),
'maxdisk': tpl.get('maxdisk', 0),
})
# Создаём группы и шаблоны с ГЛОБАЛЬНОЙ сортировкой
imported = []
global_sort_order = 0 # Глобальный счётчик для всех шаблонов
# Сортируем группы по их порядку (Ubuntu=1, Debian=2, CentOS=3...)
sorted_groups = sorted(
templates_by_group.items(),
key=lambda x: x[1]['info']['order']
)
for group_name, group_data in sorted_groups:
group_info = group_data['info']
group_templates = group_data['templates']
# Получаем или создаём группу
if group_name not in groups_cache:
# Получаем максимальный sort_order для групп
max_group_order = db.query(OSTemplateGroup).count()
new_group = OSTemplateGroup(
name=group_name,
icon=group_info['icon'],
description=f"Шаблоны {group_name}",
sort_order=group_info['order'], # Используем порядок из маппинга
is_active=True
)
db.add(new_group)
db.flush()
groups_cache[group_name] = new_group
logger.info(f"Created new group: {group_name}")
group = groups_cache[group_name]
# Сортируем шаблоны по имени для консистентности
group_templates.sort(key=lambda x: x['name'])
# Добавляем шаблоны с ГЛОБАЛЬНЫМ sort_order
for idx, tpl in enumerate(group_templates):
# Определяем дефолтные значения из размера шаблона
default_memory = max(1024, (tpl.get('maxmem', 0) // (1024 * 1024)) or 1024)
default_disk = max(10, (tpl.get('maxdisk', 0) // (1024 * 1024 * 1024)) or 10)
template = OSTemplate(
group_id=group.id,
server_id=server_id,
name=tpl['name'],
vmid=tpl['vmid'],
node=tpl.get('node'),
source_node=tpl.get('node'),
default_cores=2,
default_memory=default_memory,
default_disk=default_disk,
min_cores=1,
min_memory=512,
min_disk=5,
is_active=True,
sort_order=global_sort_order # ГЛОБАЛЬНАЯ сортировка
)
db.add(template)
imported.append({
'vmid': tpl['vmid'],
'name': tpl['name'],
'group': group_name,
'sort_order': global_sort_order
})
global_sort_order += 1 # Увеличиваем глобальный счётчик
db.commit()
logger.info(f"User {current_user.username} auto-imported {len(imported)} templates from {server.name}")
return JSONResponse(content={
'success': True,
'server_id': server_id,
'server_name': server.name,
'imported_count': len(imported),
'templates': imported,
'groups_created': [g for g in templates_by_group.keys() if g not in groups_cache]
})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error auto-importing templates from {server.name}: {e}")
db.rollback()
raise HTTPException(status_code=500, detail=f"Ошибка импорта шаблонов: {str(e)}")
# ==================== VM Deployment ====================
@router.post("/api/deploy", response_model=VMDeployResponse)
async def deploy_vm_from_template(
deploy_data: VMDeployRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.create"))
):
"""
Развернуть новую VM из шаблона.
Клонирует шаблон и настраивает параметры VM.
Поддерживает развертывание на любую ноду кластера (cross-node deployment).
"""
# Get template
template = db.query(OSTemplate).filter(OSTemplate.id == deploy_data.template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден")
if not template.is_active:
raise HTTPException(status_code=400, detail="Шаблон неактивен")
# Get server
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
# Validate resources
cores = deploy_data.cores or template.default_cores
memory = deploy_data.memory or template.default_memory
disk = deploy_data.disk or template.default_disk
if cores < template.min_cores:
raise HTTPException(status_code=400, detail=f"Минимум {template.min_cores} ядер CPU")
if memory < template.min_memory:
raise HTTPException(status_code=400, detail=f"Минимум {template.min_memory} MB памяти")
if disk < template.min_disk:
raise HTTPException(status_code=400, detail=f"Минимум {template.min_disk} GB диска")
try:
# Connect to Proxmox
if server.use_password:
client = ProxmoxClient(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl
)
else:
client = ProxmoxClient(
host=server.ip_address,
user=server.api_user,
token_name=server.api_token_name,
token_value=server.api_token_value,
verify_ssl=server.verify_ssl
)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Не удалось подключиться к Proxmox серверу")
# Determine source node (where template exists) and target node (where to deploy)
source_node = template.get_source_node()
target_node = deploy_data.target_node or source_node
# Check if template exists on source node
template_vmid = template.vmid
template_status = client.get_vm_status(source_node, template_vmid)
if not template_status:
# Try to find the template on other nodes
found = client.find_template_on_nodes(template_vmid)
if found:
source_node = found['node']
logger.info(f"Template {template_vmid} found on alternative node: {source_node}")
else:
raise HTTPException(
status_code=404,
detail=f"Шаблон VMID {template_vmid} не найден. Проверьте настройки шаблона."
)
# For shared storage: template is accessible from all nodes
# For local storage: template needs to be on the target node or replicated
#
# In a cluster with shared storage, we can clone from source_node
# but specify target_node for the new VM
clone_from_node = source_node
clone_template_vmid = template_vmid
# Log cross-node deployment
if target_node and target_node != source_node:
logger.info(f"Cross-node deployment: template on {source_node}, target node {target_node}")
# Get next VMID from database (range 10000-19999) or use provided VMID
if deploy_data.vmid:
# Используем указанный VMID (для переустановки)
new_vmid = deploy_data.vmid
logger.info(f"Using provided VMID: {new_vmid} for reinstall")
else:
# Генерируем новый случайный VMID для новой VM
new_vmid = get_next_vmid(db, server.id)
logger.info(f"Generated new VMID: {new_vmid} for server {server.id}")
# Clone VM from template
# For shared storage: clone from source node, target specifies where VM will run
clone_upid = client.clone_vm_from_template(
node=clone_from_node,
template_vmid=clone_template_vmid,
new_vmid=new_vmid,
name=deploy_data.name,
full_clone=True,
description=f"Deployed from template {template.name} by {current_user.username}",
target_storage=deploy_data.target_storage,
target_node=target_node if target_node != clone_from_node else None
)
if not clone_upid:
raise HTTPException(status_code=500, detail="Ошибка клонирования VM")
# Wait for clone to complete
clone_success = client.wait_for_task(clone_from_node, clone_upid, timeout=300)
if not clone_success:
raise HTTPException(status_code=500, detail="Таймаут клонирования VM")
# Use target_node for all subsequent operations
deploy_node = target_node
# IPAM Integration - Auto-allocate IP if network specified but no IP provided
allocated_ip = deploy_data.ip_address
ipam_allocation = None
if deploy_data.ipam_network_id and not deploy_data.ip_address:
ipam = IPAMService(db)
ipam_allocation, error = ipam.auto_allocate_ip(
network_id=deploy_data.ipam_network_id,
pool_id=deploy_data.ipam_pool_id,
resource_type='vm',
resource_name=deploy_data.name,
proxmox_server_id=server.id,
proxmox_vmid=new_vmid,
proxmox_node=deploy_node,
allocated_by=current_user.username,
notes=f"Auto-allocated for VM deployment from template {template.name}"
)
if error:
logger.error(f"IPAM auto-allocation failed: {error}")
# Continue without IPAM - let user know
else:
allocated_ip = ipam_allocation.ip_address
logger.info(f"IPAM allocated IP {allocated_ip} for VM {deploy_data.name}")
# Get gateway from network if not provided
if not deploy_data.gateway:
from ..models import IPAMNetwork
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == deploy_data.ipam_network_id).first()
if network and network.gateway:
deploy_data.gateway = network.gateway
elif deploy_data.ip_address and deploy_data.ipam_network_id:
# Register manually specified IP in IPAM
ipam = IPAMService(db)
ipam_allocation, error = ipam.allocate_ip(
ip_address=deploy_data.ip_address,
network_id=deploy_data.ipam_network_id,
pool_id=deploy_data.ipam_pool_id,
resource_type='vm',
resource_name=deploy_data.name,
proxmox_server_id=server.id,
proxmox_vmid=new_vmid,
proxmox_node=deploy_node,
allocated_by=current_user.username,
notes=f"Manually assigned for VM deployment from template {template.name}"
)
if error:
logger.warning(f"IPAM registration failed for manually specified IP: {error}")
# Configure VM
ip_config = None
if allocated_ip:
if deploy_data.gateway:
ip_config = f"ip={allocated_ip}/24,gw={deploy_data.gateway}"
else:
ip_config = f"ip={allocated_ip}/24"
elif allocated_ip is None:
ip_config = "ip=dhcp"
config_success = client.configure_vm(
node=deploy_node,
vmid=new_vmid,
cores=cores,
memory=memory,
disk_size=disk, # configure_vm will compare with current disk size
network_bridge=deploy_data.network_bridge,
cloud_init_user=deploy_data.cloud_init_user,
cloud_init_password=deploy_data.cloud_init_password,
ssh_keys=deploy_data.ssh_keys,
ip_config=ip_config,
onboot=deploy_data.onboot
)
if not config_success:
logger.warning(f"VM {new_vmid} created but configuration may be incomplete")
# Start VM if requested
if deploy_data.start_after_create:
client.start_vm(deploy_node, new_vmid)
# Enable High Availability if requested (cluster only)
ha_enabled = False
if deploy_data.enable_ha:
try:
if client.is_cluster():
ha_result = client.add_to_ha(vmid=new_vmid, vm_type='vm', max_restart=3, max_relocate=3)
if ha_result.get('success'):
ha_enabled = True
logger.info(f"HA enabled for VM {new_vmid}")
else:
logger.warning(f"Failed to enable HA for VM {new_vmid}: {ha_result.get('error')}")
else:
logger.warning(f"HA requested but server {server.name} is not in a cluster")
except Exception as ha_error:
logger.error(f"Error enabling HA for VM {new_vmid}: {ha_error}")
# Save VM configuration to database with owner
save_vm_instance(
db=db,
server_id=server.id,
vmid=new_vmid,
node=deploy_node,
vm_type='qemu',
name=deploy_data.name,
cores=cores,
memory=memory,
disk_size=disk,
ip_address=allocated_ip,
ip_prefix=24,
gateway=deploy_data.gateway,
cloud_init_user=deploy_data.cloud_init_user,
cloud_init_password=deploy_data.cloud_init_password,
ssh_keys=deploy_data.ssh_keys,
template_id=template.id,
template_name=template.name,
description=f"Deployed from template {template.name}",
owner_id=current_user.id # VPS-style user isolation
)
logger.info(f"User {current_user.username} deployed VM {new_vmid} ({deploy_data.name}) from template {template.name}")
# Log VM deployment to audit
LoggingService.log_proxmox_action(
db=db,
action="create",
resource_type="vm",
resource_id=new_vmid,
username=current_user.username,
resource_name=deploy_data.name,
server_id=server.id,
server_name=server.name,
node_name=deploy_node,
details={
"template_id": template.id,
"template_name": template.name,
"source_node": source_node if source_node != deploy_node else None,
"cores": cores,
"memory": memory,
"disk": disk,
"ip_address": allocated_ip,
"ha_enabled": ha_enabled
},
success=True
)
return VMDeployResponse(
success=True,
vmid=new_vmid,
name=deploy_data.name,
node=deploy_node,
server_id=server.id,
task_upid=clone_upid,
message=f"VM {deploy_data.name} успешно создана (VMID: {new_vmid})"
)
except HTTPException:
raise
except Exception as e:
# Log failed deployment
LoggingService.log_proxmox_action(
db=db,
action="create",
resource_type="vm",
resource_id=deploy_data.vmid,
username=current_user.username,
resource_name=deploy_data.name,
server_id=server.id if server else None,
server_name=server.name if server else None,
node_name=deploy_node if 'deploy_node' in locals() else None,
details={
"template_id": template.id if template else None,
"template_name": template.name if template else None
},
success=False,
error_message=str(e)
)
logger.error(f"Error deploying VM from template {template.id}: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка развертывания VM: {str(e)}")
# ==================== Quick Deploy (for UI) ====================
@router.get("/api/quick-deploy/{template_id}")
def get_quick_deploy_info(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("templates.view"))
):
"""Получить информацию для быстрого развертывания VM"""
template = db.query(OSTemplate).filter(OSTemplate.id == template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден")
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == template.group_id).first()
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
return JSONResponse(content={
'template': {
'id': template.id,
'name': template.name,
'vmid': template.vmid,
'node': template.node,
'description': template.description,
'default_cores': template.default_cores,
'default_memory': template.default_memory,
'default_disk': template.default_disk,
'min_cores': template.min_cores,
'min_memory': template.min_memory,
'min_disk': template.min_disk,
},
'group': {
'name': group.name if group else None,
'icon': group.icon if group else None,
},
'server': {
'id': server.id if server else None,
'name': server.name if server else None,
}
})

986
backend/app/api/users.py Normal file
View File

@@ -0,0 +1,986 @@
"""
Users and Roles Management API
"""
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field, EmailStr
from loguru import logger
from ..db import get_db
from ..models import User, Role
from ..auth import (
get_password_hash,
get_current_user,
get_current_active_admin,
PermissionChecker,
get_client_ip
)
from ..config import utcnow
from ..services.security_service import SecurityService
from ..logging_service import LoggingService
from ..template_helpers import add_i18n_context
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# ==================== HTML Pages ====================
@router.get("/users", response_class=HTMLResponse, include_in_schema=False)
async def users_page(request: Request, db: Session = Depends(get_db)):
"""Users management page (admin only) - auth checked on frontend"""
from ..i18n import t
lang = request.cookies.get("language", "en")
context = {
"request": request,
"page_title": t('nav_users', lang),
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("users.html", context)
# ==================== Schemas ====================
class RoleBase(BaseModel):
name: str = Field(..., min_length=2, max_length=50)
display_name: str = Field(..., min_length=2, max_length=100)
description: Optional[str] = None
permissions: dict = Field(default_factory=dict)
class RoleCreate(RoleBase):
pass
class RoleUpdate(BaseModel):
display_name: Optional[str] = Field(None, min_length=2, max_length=100)
description: Optional[str] = None
permissions: Optional[dict] = None
is_active: Optional[bool] = None
class RoleResponse(BaseModel):
id: int
name: str
display_name: str
description: Optional[str]
permissions: dict
is_system: bool
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
email: EmailStr
password: str = Field(..., min_length=6)
full_name: Optional[str] = Field(None, max_length=100)
role_id: Optional[int] = None
is_active: bool = True
is_admin: bool = False
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, max_length=100)
role_id: Optional[int] = None
is_active: Optional[bool] = None
is_admin: Optional[bool] = None
require_password_change: Optional[bool] = None
class UserResponse(BaseModel):
id: int
username: str
email: str
full_name: Optional[str]
is_active: bool
is_admin: bool
role_id: Optional[int]
role_name: Optional[str] = None
failed_login_attempts: int = 0
locked_until: Optional[datetime] = None
last_login: Optional[datetime] = None
created_at: datetime
class Config:
from_attributes = True
class PasswordChange(BaseModel):
new_password: str = Field(..., min_length=6)
class SecuritySettingsUpdate(BaseModel):
max_login_attempts: Optional[int] = Field(None, ge=1, le=20)
lockout_duration_minutes: Optional[int] = Field(None, ge=1, le=1440)
session_timeout_minutes: Optional[int] = Field(None, ge=5, le=1440)
ip_block_threshold: Optional[int] = Field(None, ge=1, le=100)
ip_block_duration_minutes: Optional[int] = Field(None, ge=1, le=10080)
password_min_length: Optional[int] = Field(None, ge=6, le=50)
password_require_uppercase: Optional[bool] = None
password_require_lowercase: Optional[bool] = None
password_require_numbers: Optional[bool] = None
password_require_special: Optional[bool] = None
# ==================== HTML Pages ====================
@router.get("/", response_class=HTMLResponse)
async def users_page(
request: Request,
current_user: User = Depends(PermissionChecker("users.view"))
):
"""Render users management page"""
from ..i18n import t
lang = request.cookies.get("language", "en")
context = {
"request": request,
"page_title": t('nav_users', lang),
}
context = add_i18n_context(request, context)
return templates.TemplateResponse("users.html", context)
# ==================== Role API ====================
@router.get("/api/roles", response_model=List[RoleResponse])
async def list_roles(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("roles.view"))
):
"""Get all roles"""
roles = SecurityService.get_all_roles(db, include_inactive=True)
return roles
@router.get("/api/roles/{role_id}", response_model=RoleResponse)
async def get_role(
role_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("roles.view"))
):
"""Get role by ID"""
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=404, detail="Role not found")
return role
@router.post("/api/roles", response_model=RoleResponse, status_code=201)
async def create_role(
role_data: RoleCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("roles.manage"))
):
"""Create a new role"""
# Check if role name exists
existing = db.query(Role).filter(Role.name == role_data.name.lower()).first()
if existing:
raise HTTPException(status_code=400, detail="Role name already exists")
role = SecurityService.create_role(
db=db,
name=role_data.name,
display_name=role_data.display_name,
description=role_data.description,
permissions=role_data.permissions
)
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="role_created",
message=f"Role '{role.name}' created by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="role",
resource_id=str(role.id),
resource_name=role.name
)
return role
@router.put("/api/roles/{role_id}", response_model=RoleResponse)
async def update_role(
role_id: int,
role_data: RoleUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("roles.manage"))
):
"""Update a role"""
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=404, detail="Role not found")
# Update fields
if role_data.display_name is not None:
role.display_name = role_data.display_name
if role_data.description is not None:
role.description = role_data.description
if role_data.permissions is not None:
role.permissions = role_data.permissions
if role_data.is_active is not None:
role.is_active = role_data.is_active
role.updated_at = utcnow()
db.commit()
db.refresh(role)
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="role_updated",
message=f"Role '{role.name}' updated by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="role",
resource_id=str(role.id),
resource_name=role.name
)
return role
@router.delete("/api/roles/{role_id}")
async def delete_role(
role_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("roles.manage"))
):
"""Delete a role"""
success, message = SecurityService.delete_role(db, role_id)
if not success:
raise HTTPException(status_code=400, detail=message)
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.AUTH,
action="role_deleted",
message=f"Role ID {role_id} deleted by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="role",
resource_id=str(role_id)
)
return {"message": "Role deleted"}
@router.get("/api/permissions")
async def get_all_permissions(
current_user: User = Depends(PermissionChecker("roles.view"))
):
"""Get all available permissions (legacy format for backwards compatibility)"""
return SecurityService.get_all_permissions()
@router.get("/api/permissions/v2")
async def get_all_permissions_v2(
current_user: User = Depends(PermissionChecker("roles.view"))
):
"""
Get all available permissions in new format.
Returns permissions grouped by category with resource:action codes.
"""
return SecurityService.get_all_permissions_v2()
# ==================== User API ====================
@router.get("/api/users", response_model=List[UserResponse])
async def list_users(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.view"))
):
"""Get all users"""
users = db.query(User).order_by(User.username).all()
# Add role_name
result = []
for user in users:
user_dict = {
"id": user.id,
"username": user.username,
"email": user.email,
"full_name": user.full_name,
"is_active": user.is_active,
"is_admin": user.is_admin,
"role_id": user.role_id,
"role_name": user.role.display_name if user.role else ("Administrator" if user.is_admin else "User"),
"failed_login_attempts": user.failed_login_attempts or 0,
"locked_until": user.locked_until,
"last_login": user.last_login,
"created_at": user.created_at
}
result.append(user_dict)
return result
@router.get("/api/users/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.view"))
):
"""Get user by ID"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {
"id": user.id,
"username": user.username,
"email": user.email,
"full_name": user.full_name,
"is_active": user.is_active,
"is_admin": user.is_admin,
"role_id": user.role_id,
"role_name": user.role.display_name if user.role else None,
"failed_login_attempts": user.failed_login_attempts or 0,
"locked_until": user.locked_until,
"last_login": user.last_login,
"created_at": user.created_at
}
@router.post("/api/users", response_model=UserResponse, status_code=201)
async def create_user(
user_data: UserCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.create"))
):
"""Create a new user"""
# Check username
if db.query(User).filter(User.username == user_data.username).first():
raise HTTPException(status_code=400, detail="Username already exists")
# Check email
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(status_code=400, detail="Email already exists")
# Validate password
is_valid, errors = SecurityService.validate_password(db, user_data.password)
if not is_valid:
raise HTTPException(status_code=400, detail="; ".join(errors))
# Validate role
if user_data.role_id:
role = db.query(Role).filter(Role.id == user_data.role_id).first()
if not role:
raise HTTPException(status_code=400, detail="Invalid role")
# Create user
user = User(
username=user_data.username,
email=user_data.email,
hashed_password=get_password_hash(user_data.password),
full_name=user_data.full_name,
role_id=user_data.role_id,
is_active=user_data.is_active,
is_admin=user_data.is_admin,
last_password_change=utcnow()
)
db.add(user)
db.commit()
db.refresh(user)
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="user_created",
message=f"User '{user.username}' created by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user.id),
resource_name=user.username
)
return {
"id": user.id,
"username": user.username,
"email": user.email,
"full_name": user.full_name,
"is_active": user.is_active,
"is_admin": user.is_admin,
"role_id": user.role_id,
"role_name": user.role.display_name if user.role else None,
"failed_login_attempts": 0,
"locked_until": None,
"last_login": None,
"created_at": user.created_at
}
@router.put("/api/users/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
user_data: UserUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.edit"))
):
"""Update a user"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent editing admin by non-admin
if user.is_admin and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Cannot edit admin user")
# Update fields
if user_data.email is not None:
# Check email uniqueness
existing = db.query(User).filter(User.email == user_data.email, User.id != user_id).first()
if existing:
raise HTTPException(status_code=400, detail="Email already exists")
user.email = user_data.email
if user_data.full_name is not None:
user.full_name = user_data.full_name
if user_data.role_id is not None:
role = db.query(Role).filter(Role.id == user_data.role_id).first()
if not role:
raise HTTPException(status_code=400, detail="Invalid role")
user.role_id = user_data.role_id
if user_data.is_active is not None:
# Cannot deactivate yourself
if user_id == current_user.id and not user_data.is_active:
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
user.is_active = user_data.is_active
# Terminate sessions if deactivating
if not user_data.is_active:
SecurityService.terminate_all_user_sessions(db, user_id)
if user_data.require_password_change is not None:
user.require_password_change = user_data.require_password_change
if user_data.is_admin is not None:
# Only admin can change admin status
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only admin can change admin status")
# Cannot remove own admin status
if user_id == current_user.id and not user_data.is_admin:
raise HTTPException(status_code=400, detail="Cannot remove your own admin status")
user.is_admin = user_data.is_admin
user.updated_at = utcnow()
db.commit()
db.refresh(user)
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="user_updated",
message=f"User '{user.username}' updated by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user.id),
resource_name=user.username
)
return {
"id": user.id,
"username": user.username,
"email": user.email,
"full_name": user.full_name,
"is_active": user.is_active,
"is_admin": user.is_admin,
"role_id": user.role_id,
"role_name": user.role.display_name if user.role else None,
"failed_login_attempts": user.failed_login_attempts or 0,
"locked_until": user.locked_until,
"last_login": user.last_login,
"created_at": user.created_at
}
@router.delete("/api/users/{user_id}")
async def delete_user(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.delete"))
):
"""Delete a user"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Cannot delete yourself
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
# Cannot delete admin
if user.is_admin:
raise HTTPException(status_code=400, detail="Cannot delete admin user")
username = user.username
# Terminate all sessions
SecurityService.terminate_all_user_sessions(db, user_id)
db.delete(user)
db.commit()
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.AUTH,
action="user_deleted",
message=f"User '{username}' deleted by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user_id),
resource_name=username
)
return {"message": "User deleted"}
@router.post("/api/users/{user_id}/reset-password")
async def reset_user_password(
user_id: int,
password_data: PasswordChange,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.edit"))
):
"""Reset user password (admin action)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Validate password
is_valid, errors = SecurityService.validate_password(db, password_data.new_password)
if not is_valid:
raise HTTPException(status_code=400, detail="; ".join(errors))
user.hashed_password = get_password_hash(password_data.new_password)
user.last_password_change = utcnow()
user.require_password_change = True # Force password change on next login
user.failed_login_attempts = 0
user.locked_until = None
db.commit()
# Terminate all sessions
SecurityService.terminate_all_user_sessions(db, user_id)
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.AUTH,
action="password_reset",
message=f"Password reset for user '{user.username}' by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user.id),
resource_name=user.username
)
return {"message": "Password reset successfully"}
@router.post("/api/users/{user_id}/unlock")
async def unlock_user(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.edit"))
):
"""Unlock a locked user account"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.failed_login_attempts = 0
user.locked_until = None
db.commit()
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="user_unlocked",
message=f"User '{user.username}' unlocked by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user.id),
resource_name=user.username
)
return {"message": "User unlocked"}
@router.post("/api/users/{user_id}/terminate-sessions")
async def terminate_user_sessions(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.edit"))
):
"""Terminate all sessions for a user"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
count = SecurityService.terminate_all_user_sessions(db, user_id)
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="sessions_terminated",
message=f"Terminated {count} sessions for user '{user.username}' by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user.id),
resource_name=user.username
)
return {"message": f"Terminated {count} sessions"}
# ==================== Security Settings API ====================
@router.get("/api/security/settings")
async def get_security_settings(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.security"))
):
"""Get security settings"""
return {
"max_login_attempts": SecurityService.get_setting_int(db, "max_login_attempts", 5),
"lockout_duration_minutes": SecurityService.get_setting_int(db, "lockout_duration_minutes", 30),
"session_timeout_minutes": SecurityService.get_setting_int(db, "session_timeout_minutes", 60),
"ip_block_threshold": SecurityService.get_setting_int(db, "ip_block_threshold", 10),
"ip_block_duration_minutes": SecurityService.get_setting_int(db, "ip_block_duration_minutes", 60),
"password_min_length": SecurityService.get_setting_int(db, "password_min_length", 8),
"password_require_uppercase": SecurityService.get_setting_bool(db, "password_require_uppercase", True),
"password_require_lowercase": SecurityService.get_setting_bool(db, "password_require_lowercase", True),
"password_require_numbers": SecurityService.get_setting_bool(db, "password_require_numbers", True),
"password_require_special": SecurityService.get_setting_bool(db, "password_require_special", False),
}
@router.put("/api/security/settings")
async def update_security_settings(
settings_data: SecuritySettingsUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.security"))
):
"""Update security settings"""
updated = []
for key, value in settings_data.model_dump(exclude_unset=True).items():
SecurityService.update_setting(db, key, str(value).lower() if isinstance(value, bool) else str(value))
updated.append(key)
if updated:
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="security_settings_updated",
message=f"Security settings updated by {current_user.username}: {', '.join(updated)}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request)
)
return {"message": "Settings updated", "updated": updated}
# ==================== Blocked IPs API ====================
@router.get("/api/security/blocked-ips")
async def list_blocked_ips(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.security"))
):
"""Get list of blocked IPs"""
from ..models import BlockedIP
blocked = db.query(BlockedIP).order_by(BlockedIP.blocked_at.desc()).all()
return [{
"id": ip.id,
"ip_address": ip.ip_address,
"reason": ip.reason,
"blocked_by": ip.blocked_by,
"blocked_at": ip.blocked_at.isoformat() if ip.blocked_at else None,
"expires_at": ip.expires_at.isoformat() if ip.expires_at else None,
"is_permanent": ip.is_permanent,
"attempts_count": ip.attempts_count,
"is_active": ip.is_blocked()
} for ip in blocked]
@router.delete("/api/security/blocked-ips/{ip_address:path}")
async def unblock_ip(
ip_address: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.security"))
):
"""Unblock an IP address"""
if SecurityService.unblock_ip(db, ip_address):
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="ip_unblocked",
message=f"IP {ip_address} unblocked by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request)
)
return {"message": f"IP {ip_address} unblocked"}
raise HTTPException(status_code=404, detail="IP not found in blocked list")
# ==================== Sessions API ====================
@router.get("/api/sessions")
async def list_sessions(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.view"))
):
"""Get list of active sessions"""
from ..models import ActiveSession
sessions = db.query(ActiveSession).filter(
ActiveSession.expires_at > utcnow()
).order_by(ActiveSession.last_activity.desc()).all()
result = []
for session in sessions:
user = db.query(User).filter(User.id == session.user_id).first()
result.append({
"id": session.id,
"user_id": session.user_id,
"username": user.username if user else "Unknown",
"ip_address": session.ip_address,
"device_info": session.device_info,
"created_at": session.created_at.isoformat() if session.created_at else None,
"last_activity": session.last_activity.isoformat() if session.last_activity else None,
"expires_at": session.expires_at.isoformat() if session.expires_at else None,
})
return result
@router.delete("/api/sessions/{session_id}")
async def terminate_session(
session_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.edit"))
):
"""Terminate a specific session"""
from ..models import ActiveSession
session = db.query(ActiveSession).filter(ActiveSession.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
db.delete(session)
db.commit()
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="session_terminated",
message=f"Session {session_id} terminated by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request)
)
return {"message": "Session terminated"}
@router.post("/api/sessions/terminate-all")
async def terminate_all_sessions(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.edit"))
):
"""Terminate all sessions except current user's"""
from ..models import ActiveSession
# Delete all sessions except current user's
deleted = db.query(ActiveSession).filter(
ActiveSession.user_id != current_user.id
).delete()
db.commit()
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.AUTH,
action="all_sessions_terminated",
message=f"All sessions ({deleted}) terminated by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request)
)
return {"message": f"Terminated {deleted} sessions"}
# ==================== Security Events API ====================
@router.get("/api/security/events")
async def list_security_events(
limit: int = 50,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.security"))
):
"""Get security events from logs"""
from ..models import AuditLog
# Get auth-related logs
events = db.query(AuditLog).filter(
AuditLog.category == "auth"
).order_by(AuditLog.created_at.desc()).limit(limit).all()
return [{
"id": event.id,
"event_type": event.action,
"ip_address": event.ip_address,
"username": event.username,
"message": event.message,
"created_at": event.created_at.isoformat() if event.created_at else None,
} for event in events]
# ==================== Block IP API ====================
@router.post("/api/security/blocked-ips")
async def block_ip(
ip_data: dict,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.security"))
):
"""Block an IP address manually"""
from ..models import BlockedIP
ip_address = ip_data.get("ip_address")
reason = ip_data.get("reason", "Manual block")
duration_minutes = ip_data.get("duration_minutes")
if not ip_address:
raise HTTPException(status_code=400, detail="IP address is required")
# Check if already blocked
existing = db.query(BlockedIP).filter(BlockedIP.ip_address == ip_address).first()
if existing:
raise HTTPException(status_code=400, detail="IP is already blocked")
# Calculate expiry
expires_at = None
is_permanent = True
if duration_minutes:
expires_at = utcnow() + timedelta(minutes=int(duration_minutes))
is_permanent = False
blocked_ip = BlockedIP(
ip_address=ip_address,
reason=reason,
blocked_by=current_user.username,
blocked_at=utcnow(),
expires_at=expires_at,
is_permanent=is_permanent
)
db.add(blocked_ip)
db.commit()
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.AUTH,
action="ip_blocked_manual",
message=f"IP {ip_address} blocked manually by {current_user.username}: {reason}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request)
)
return {"message": f"IP {ip_address} blocked", "id": blocked_ip.id}
@router.delete("/api/security/blocked-ips/{blocked_id}")
async def unblock_ip_by_id(
blocked_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.security"))
):
"""Unblock an IP address by ID"""
from ..models import BlockedIP
blocked_ip = db.query(BlockedIP).filter(BlockedIP.id == blocked_id).first()
if not blocked_ip:
raise HTTPException(status_code=404, detail="Blocked IP not found")
ip_address = blocked_ip.ip_address
db.delete(blocked_ip)
db.commit()
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="ip_unblocked",
message=f"IP {ip_address} unblocked by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request)
)
return {"message": f"IP {ip_address} unblocked"}