Files
pvemanager/backend/app/logging_service.py
markmorado bba66d2d2c fix: add missing action mappings for reinstall and change-password in LoggingService
- Added 'reinstall': 'reinstalled' mapping in action_messages
- Added 'change-password': 'password changed' mapping in action_messages
- Fixes HTTP 400 Invalid action error for new VM/LXC operations
- All operations now properly logged to audit_logs table

Related operations:
- POST /api/{server_id}/vm/{vmid}/reinstall
- POST /api/{server_id}/vm/{vmid}/change-password
- POST /api/{server_id}/container/{vmid}/change-password

Adds:
- BUGFIX_REPORT.md - detailed bug analysis and fix
- TEST_NEW_OPERATIONS.md - operation documentation
- test_new_operations.py - verification script
2026-04-29 22:37:52 +02:00

562 lines
19 KiB
Python

"""
Logging Service for PVEmanager
Provides centralized logging functionality for audit, system, and API events
"""
import json
import uuid
import traceback
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, List
from sqlalchemy.orm import Session
from sqlalchemy import desc, and_, or_
from loguru import logger
from .models import AuditLog
def utcnow() -> datetime:
"""Get current UTC time as timezone-aware datetime"""
return datetime.now(timezone.utc)
class LoggingService:
"""Centralized logging service for the application"""
# Log levels
DEBUG = "debug"
INFO = "info"
WARNING = "warning"
ERROR = "error"
CRITICAL = "critical"
# Log categories
AUTH = "auth"
PROXMOX = "proxmox"
IPAM = "ipam"
SYSTEM = "system"
API = "api"
DOCKER = "docker"
# Sensitive fields to mask in logs
SENSITIVE_FIELDS = {'password', 'token', 'secret', 'api_key', 'apikey', 'access_token', 'refresh_token'}
@staticmethod
def generate_request_id() -> str:
"""Generate a unique request ID"""
return str(uuid.uuid4())
@staticmethod
def mask_sensitive_data(data: Any, depth: int = 0) -> Any:
"""Mask sensitive fields in data for safe logging"""
if depth > 10: # Prevent infinite recursion
return data
if isinstance(data, dict):
masked = {}
for key, value in data.items():
key_lower = key.lower()
if any(sensitive in key_lower for sensitive in LoggingService.SENSITIVE_FIELDS):
masked[key] = "***MASKED***"
else:
masked[key] = LoggingService.mask_sensitive_data(value, depth + 1)
return masked
elif isinstance(data, list):
return [LoggingService.mask_sensitive_data(item, depth + 1) for item in data]
return data
@staticmethod
def truncate_data(data: Any, max_size: int = 5000) -> Any:
"""Truncate large data for storage"""
if data is None:
return None
if isinstance(data, str):
if len(data) > max_size:
return data[:max_size] + f"... (truncated, {len(data)} total chars)"
return data
if isinstance(data, dict):
json_str = json.dumps(data)
if len(json_str) > max_size:
return {"_truncated": True, "_size": len(json_str), "_preview": json_str[:500]}
return data
return data
@staticmethod
def log(
db: Session,
level: str,
category: str,
action: str,
message: str,
user_id: Optional[int] = None,
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[str, Any]] = None,
request_method: Optional[str] = None,
request_path: Optional[str] = None,
response_status: Optional[int] = None,
duration_ms: Optional[int] = None,
success: bool = True,
error_message: Optional[str] = None,
# New enhanced 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[str, Any]] = None,
response_body: Optional[Dict[str, Any]] = None,
query_params: Optional[str] = None,
error_traceback: Optional[str] = None
) -> AuditLog:
"""
Create a new audit log entry with enhanced details
Args:
db: Database session
level: Log level (debug, info, warning, error, critical)
category: Log category (auth, proxmox, ipam, system, api)
action: Action performed (login, vm_start, etc.)
message: Human-readable message
user_id: User ID (optional)
username: Username (optional)
ip_address: Client IP address (optional)
user_agent: Client user agent (optional)
resource_type: Type of resource (vm, container, etc.)
resource_id: ID of the resource
resource_name: Name of the resource
details: Additional details as JSON
request_method: HTTP method
request_path: HTTP path
response_status: HTTP response status code
duration_ms: Request duration in milliseconds
success: Whether the action was successful
error_message: Error message if failed
request_id: Unique request ID for correlation
session_id: User session ID
geo_location: Geographic location from IP
server_id: Proxmox server ID
server_name: Proxmox server name
node_name: Proxmox node name
request_body: Request body (sensitive data masked)
response_body: Response body (truncated)
query_params: Query string parameters
error_traceback: Stack trace for errors
Returns:
Created AuditLog instance
"""
try:
# Mask sensitive data in request body
safe_request_body = None
if request_body:
safe_request_body = LoggingService.mask_sensitive_data(request_body)
safe_request_body = LoggingService.truncate_data(safe_request_body)
# Truncate response body
safe_response_body = LoggingService.truncate_data(response_body) if response_body else None
# Mask sensitive data in details
safe_details = LoggingService.mask_sensitive_data(details) if details else None
log_entry = AuditLog(
level=level,
category=category,
action=action,
message=message,
request_id=request_id,
session_id=session_id,
user_id=user_id,
username=username,
ip_address=ip_address,
user_agent=user_agent,
geo_location=geo_location,
resource_type=resource_type,
resource_id=str(resource_id) if resource_id else None,
resource_name=resource_name,
server_id=server_id,
server_name=server_name,
node_name=node_name,
details=safe_details,
request_body=safe_request_body,
response_body=safe_response_body,
request_method=request_method,
request_path=request_path,
query_params=query_params,
response_status=response_status,
duration_ms=duration_ms,
success=success,
error_message=error_message,
error_traceback=error_traceback
)
db.add(log_entry)
db.commit()
db.refresh(log_entry)
# Also log to file/console via loguru with request_id for correlation
log_method = getattr(logger, level, logger.info)
log_prefix = f"[{request_id[:8]}] " if request_id else ""
log_method(f"{log_prefix}[{category.upper()}] {action}: {message}")
return log_entry
except Exception as e:
logger.error(f"Failed to create audit log: {e}")
db.rollback()
return None
@staticmethod
def log_auth(
db: Session,
action: str,
username: str,
ip_address: str,
success: bool = True,
error_message: Optional[str] = None,
user_agent: Optional[str] = None,
user_id: Optional[int] = None,
details: Optional[Dict[str, Any]] = None
) -> AuditLog:
"""Log authentication events"""
level = LoggingService.INFO if success else LoggingService.WARNING
message = f"User '{username}' {action}"
if not success:
message += f" failed: {error_message}"
return LoggingService.log(
db=db,
level=level,
category=LoggingService.AUTH,
action=action,
message=message,
user_id=user_id,
username=username,
ip_address=ip_address,
user_agent=user_agent,
success=success,
error_message=error_message,
details=details
)
@staticmethod
def log_proxmox_action(
db: Session,
action: str,
resource_type: str,
resource_id: Any,
username: Optional[str] = None,
resource_name: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
success: bool = True,
error_message: Optional[str] = None,
ip_address: Optional[str] = None,
server_id: Optional[int] = None,
server_name: Optional[str] = None,
node_name: Optional[str] = None,
request_id: Optional[str] = None,
error_traceback: Optional[str] = None
) -> AuditLog:
"""Log Proxmox actions (VM/Container operations)"""
level = LoggingService.INFO if success else LoggingService.ERROR
action_messages = {
"start": "started",
"stop": "stopped",
"restart": "restarted",
"shutdown": "shutdown",
"delete": "deleted",
"create": "created",
"clone": "cloned",
"migrate": "migrated",
"backup": "backup created",
"restore": "restored",
"snapshot": "snapshot created",
"resize": "disk resized",
"console": "console accessed",
"config_update": "config updated",
"reinstall": "reinstalled",
"change-password": "password changed"
}
action_text = action_messages.get(action, action)
type_text = "VM" if resource_type == "vm" else "Container" if resource_type == "container" else resource_type
message = f"{type_text} {resource_id}"
if resource_name:
message += f" ({resource_name})"
message += f" {action_text}"
if node_name:
message += f" on node {node_name}"
if server_name:
message += f" [{server_name}]"
if username:
message += f" by {username}"
if not success:
message += f" - ERROR: {error_message}"
# Enhance details with server context
enhanced_details = details.copy() if details else {}
if server_id:
enhanced_details['server_id'] = server_id
if server_name:
enhanced_details['server_name'] = server_name
if node_name:
enhanced_details['node'] = node_name
return LoggingService.log(
db=db,
level=level,
category=LoggingService.PROXMOX,
action=f"{resource_type}_{action}",
message=message,
username=username,
ip_address=ip_address,
resource_type=resource_type,
resource_id=resource_id,
resource_name=resource_name,
server_id=server_id,
server_name=server_name,
node_name=node_name,
details=enhanced_details,
success=success,
error_message=error_message,
request_id=request_id,
error_traceback=error_traceback
)
@staticmethod
def log_ipam_action(
db: Session,
action: str,
resource_type: str,
resource_id: Any,
username: Optional[str] = None,
resource_name: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
ip_address: Optional[str] = None
) -> AuditLog:
"""Log IPAM actions"""
action_messages = {
"allocate": "выделен IP адрес",
"release": "освобождён IP адрес",
"create_network": "создана сеть",
"delete_network": "удалена сеть",
"create_pool": "создан пул адресов",
"delete_pool": "удалён пул адресов"
}
message = action_messages.get(action, action)
if resource_name:
message += f": {resource_name}"
if username:
message += f" (пользователь: {username})"
return LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.IPAM,
action=action,
message=message,
username=username,
ip_address=ip_address,
resource_type=resource_type,
resource_id=resource_id,
resource_name=resource_name,
details=details
)
@staticmethod
def log_api_request(
db: Session,
method: str,
path: str,
status_code: int,
duration_ms: int,
ip_address: Optional[str] = None,
username: Optional[str] = None,
user_agent: Optional[str] = None,
error_message: Optional[str] = None
) -> AuditLog:
"""Log API requests"""
level = LoggingService.DEBUG
if status_code >= 500:
level = LoggingService.ERROR
elif status_code >= 400:
level = LoggingService.WARNING
success = status_code < 400
message = f"{method} {path} - {status_code} ({duration_ms}ms)"
return LoggingService.log(
db=db,
level=level,
category=LoggingService.API,
action="api_request",
message=message,
username=username,
ip_address=ip_address,
user_agent=user_agent,
request_method=method,
request_path=path,
response_status=status_code,
duration_ms=duration_ms,
success=success,
error_message=error_message
)
@staticmethod
def log_system(
db: Session,
action: str,
message: str,
level: str = "info",
details: Optional[Dict[str, Any]] = None
) -> AuditLog:
"""Log system events"""
return LoggingService.log(
db=db,
level=level,
category=LoggingService.SYSTEM,
action=action,
message=message,
details=details
)
@staticmethod
def get_logs(
db: Session,
level: Optional[str] = None,
category: Optional[str] = None,
username: Optional[str] = None,
ip_address: Optional[str] = None,
resource_type: Optional[str] = None,
resource_id: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
search: Optional[str] = None,
request_id: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> tuple[List[AuditLog], int]:
"""
Get logs with filtering
Returns:
Tuple of (logs list, total count)
"""
query = db.query(AuditLog)
# Apply filters
if level:
query = query.filter(AuditLog.level == level)
if category:
query = query.filter(AuditLog.category == category)
if username:
query = query.filter(AuditLog.username.ilike(f"%{username}%"))
if ip_address:
query = query.filter(AuditLog.ip_address.ilike(f"%{ip_address}%"))
if resource_type:
query = query.filter(AuditLog.resource_type == resource_type)
if resource_id:
query = query.filter(AuditLog.resource_id == resource_id)
if date_from:
query = query.filter(AuditLog.created_at >= date_from)
if date_to:
query = query.filter(AuditLog.created_at <= date_to)
if request_id:
query = query.filter(AuditLog.request_id == request_id)
if search:
query = query.filter(
or_(
AuditLog.message.ilike(f"%{search}%"),
AuditLog.action.ilike(f"%{search}%"),
AuditLog.resource_name.ilike(f"%{search}%"),
AuditLog.request_id.ilike(f"%{search}%")
)
)
# Get total count
total = query.count()
# Apply pagination and ordering
logs = query.order_by(desc(AuditLog.created_at)).offset(offset).limit(limit).all()
return logs, total
@staticmethod
def get_stats(db: Session, hours: int = 24) -> Dict[str, Any]:
"""Get log statistics for the specified period"""
from sqlalchemy import func
since = utcnow() - timedelta(hours=hours)
# Total logs
total = db.query(AuditLog).filter(AuditLog.created_at >= since).count()
# By level
by_level = {}
for level in ['debug', 'info', 'warning', 'error', 'critical']:
count = db.query(AuditLog).filter(
and_(AuditLog.created_at >= since, AuditLog.level == level)
).count()
by_level[level] = count
# By category
by_category = {}
for category in ['auth', 'proxmox', 'ipam', 'system', 'api', 'docker']:
count = db.query(AuditLog).filter(
and_(AuditLog.created_at >= since, AuditLog.category == category)
).count()
by_category[category] = count
# Recent errors
errors = db.query(AuditLog).filter(
and_(
AuditLog.created_at >= since,
AuditLog.level.in_(['error', 'critical'])
)
).order_by(desc(AuditLog.created_at)).limit(5).all()
# Failed logins
failed_logins = db.query(AuditLog).filter(
and_(
AuditLog.created_at >= since,
AuditLog.category == 'auth',
AuditLog.success == False
)
).count()
return {
"period_hours": hours,
"total": total,
"by_level": by_level,
"by_category": by_category,
"errors_count": by_level.get('error', 0) + by_level.get('critical', 0),
"failed_logins": failed_logins,
"recent_errors": [
{
"id": e.id,
"level": e.level,
"category": e.category,
"action": e.action,
"message": e.message,
"created_at": e.created_at.isoformat() if e.created_at else None
}
for e in errors
]
}
@staticmethod
def cleanup_old_logs(db: Session, days: int = 30) -> int:
"""Delete logs older than specified days"""
cutoff = utcnow() - timedelta(days=days)
deleted = db.query(AuditLog).filter(AuditLog.created_at < cutoff).delete()
db.commit()
logger.info(f"Cleaned up {deleted} audit logs older than {days} days")
return deleted