Add JWT authentication with user/password login, role-based access control, and Bearer token support while maintaining legacy API key compatibility for transition; update README with comprehensive JWT security guidelines, installation instructions for Python/SSH, development startup scripts (.env, run_dev.sh/ps1), and migrate all API examples to JWT authentication; update Ansible inventory SSH key paths from Docker container paths to local user paths

This commit is contained in:
2025-12-14 17:33:34 -05:00
parent 5a512d39b5
commit 0030fcc101
177 changed files with 22810 additions and 289 deletions
+43
View File
@@ -0,0 +1,43 @@
"""
Core module - Configuration, constantes, exceptions et dépendances.
"""
from app.core.config import settings
from app.core.constants import (
HostStatus,
TaskStatus,
LogLevel,
ScheduleStatus,
NotificationType,
ACTION_PLAYBOOK_MAP,
)
from app.core.exceptions import (
HomelabException,
HostNotFoundException,
TaskNotFoundException,
ScheduleNotFoundException,
PlaybookNotFoundException,
GroupNotFoundException,
ValidationException,
AnsibleExecutionException,
BootstrapException,
)
__all__ = [
"settings",
"HostStatus",
"TaskStatus",
"LogLevel",
"ScheduleStatus",
"NotificationType",
"ACTION_PLAYBOOK_MAP",
"HomelabException",
"HostNotFoundException",
"TaskNotFoundException",
"ScheduleNotFoundException",
"PlaybookNotFoundException",
"GroupNotFoundException",
"ValidationException",
"AnsibleExecutionException",
"BootstrapException",
]
+123
View File
@@ -0,0 +1,123 @@
"""
Configuration centralisée de l'application.
Toutes les variables d'environnement et paramètres sont centralisés ici.
"""
import os
from pathlib import Path
from typing import Optional
from pydantic import Field
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Configuration de l'application Homelab Automation."""
# === Chemins ===
base_dir: Path = Field(default_factory=lambda: Path(__file__).resolve().parent.parent)
logs_dir: Path = Field(default_factory=lambda: Path(os.environ.get("LOGS_DIR", "/logs")))
@property
def ansible_dir(self) -> Path:
"""Répertoire Ansible (relatif à base_dir.parent)"""
return self.base_dir.parent / "ansible"
@property
def tasks_logs_dir(self) -> Path:
"""Répertoire des logs de tâches markdown"""
return Path(os.environ.get("DIR_LOGS_TASKS", str(self.base_dir.parent / "tasks_logs")))
@property
def db_path(self) -> Path:
"""Chemin de la base de données SQLite"""
return self.logs_dir / "homelab.db"
# === SSH ===
ssh_key_path: str = Field(
default_factory=lambda: os.environ.get("SSH_KEY_PATH", str(Path.home() / ".ssh" / "id_rsa"))
)
ssh_user: str = Field(default_factory=lambda: os.environ.get("SSH_USER", "automation"))
ssh_remote_user: str = Field(default_factory=lambda: os.environ.get("SSH_REMOTE_USER", "root"))
# === API ===
api_key: str = Field(default_factory=lambda: os.environ.get("API_KEY", "dev-key-12345"))
api_title: str = "Homelab Automation Dashboard API"
api_version: str = "1.0.0"
api_description: str = "API REST moderne pour la gestion automatique d'homelab"
# === JWT Authentication ===
jwt_secret_key: str = Field(
default_factory=lambda: os.environ.get("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
)
jwt_expire_minutes: int = Field(
default_factory=lambda: int(os.environ.get("JWT_EXPIRE_MINUTES", "1440"))
)
jwt_algorithm: str = "HS256"
# === Database ===
database_url: Optional[str] = Field(default=None)
@property
def async_database_url(self) -> str:
"""URL de connexion async pour SQLAlchemy"""
if self.database_url:
return self.database_url
return f"sqlite+aiosqlite:///{self.db_path}"
# === CORS ===
cors_origins: list = Field(default=["*"])
cors_allow_credentials: bool = True
cors_allow_methods: list = Field(default=["*"])
cors_allow_headers: list = Field(default=["*"])
# === Notifications ntfy ===
ntfy_enabled: bool = Field(
default_factory=lambda: os.environ.get("NTFY_ENABLED", "true").lower() == "true"
)
ntfy_base_url: str = Field(
default_factory=lambda: os.environ.get("NTFY_BASE_URL", "https://ntfy.sh")
)
ntfy_default_topic: str = Field(
default_factory=lambda: os.environ.get("NTFY_TOPIC", "homelab-automation")
)
ntfy_timeout: int = Field(
default_factory=lambda: int(os.environ.get("NTFY_TIMEOUT", "10"))
)
ntfy_username: Optional[str] = Field(
default_factory=lambda: os.environ.get("NTFY_USERNAME")
)
ntfy_password: Optional[str] = Field(
default_factory=lambda: os.environ.get("NTFY_PASSWORD")
)
ntfy_token: Optional[str] = Field(
default_factory=lambda: os.environ.get("NTFY_TOKEN")
)
# === Scheduler ===
scheduler_timezone: str = Field(
default_factory=lambda: os.environ.get("SCHEDULER_TIMEZONE", "America/Montreal")
)
scheduler_misfire_grace_time: int = 300
# === Cache ===
hosts_cache_ttl: int = 60 # secondes
inventory_cache_ttl: int = 60 # secondes
logs_index_rebuild_interval: int = 60 # secondes
# === Server ===
host: str = "0.0.0.0"
port: int = 8008
reload: bool = Field(
default_factory=lambda: os.environ.get("RELOAD", "true").lower() == "true"
)
log_level: str = "info"
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
extra = "ignore"
# Instance singleton de la configuration
settings = Settings()
+150
View File
@@ -0,0 +1,150 @@
"""
Constantes et énumérations de l'application.
Centralise toutes les valeurs constantes pour éviter les magic strings.
"""
from enum import Enum
from typing import Dict
class HostStatus(str, Enum):
"""Statuts possibles d'un hôte."""
ONLINE = "online"
OFFLINE = "offline"
WARNING = "warning"
UNKNOWN = "unknown"
class TaskStatus(str, Enum):
"""Statuts possibles d'une tâche."""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class LogLevel(str, Enum):
"""Niveaux de log."""
DEBUG = "DEBUG"
INFO = "INFO"
WARN = "WARN"
WARNING = "WARNING"
ERROR = "ERROR"
class ScheduleStatus(str, Enum):
"""Statuts possibles d'un schedule."""
NEVER = "never"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
CANCELED = "canceled"
class ScheduleType(str, Enum):
"""Types de schedule."""
ONCE = "once"
RECURRING = "recurring"
class RecurrenceType(str, Enum):
"""Types de récurrence."""
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY = "monthly"
CUSTOM = "custom"
class TargetType(str, Enum):
"""Types de cible pour les schedules."""
GROUP = "group"
HOST = "host"
class NotificationType(str, Enum):
"""Types de notification pour les schedules."""
NONE = "none"
ALL = "all"
ERRORS = "errors"
class SourceType(str, Enum):
"""Types de source pour les tâches."""
SCHEDULED = "scheduled"
MANUAL = "manual"
ADHOC = "adhoc"
class AnsibleModule(str, Enum):
"""Modules Ansible courants pour les commandes ad-hoc."""
SHELL = "shell"
COMMAND = "command"
RAW = "raw"
PING = "ping"
SETUP = "setup"
class GroupType(str, Enum):
"""Types de groupes Ansible."""
ENV = "env"
ROLE = "role"
# === Mapping Actions → Playbooks ===
ACTION_PLAYBOOK_MAP: Dict[str, str] = {
"upgrade": "vm-upgrade.yml",
"reboot": "vm-reboot.yml",
"health-check": "health-check.yml",
"backup": "backup-config.yml",
"bootstrap": "bootstrap-host.yml",
}
# === Noms lisibles des actions ===
ACTION_DISPLAY_NAMES: Dict[str, str] = {
"upgrade": "Mise à jour système",
"reboot": "Redémarrage système",
"health-check": "Vérification de santé",
"backup": "Sauvegarde",
"deploy": "Déploiement",
"rollback": "Rollback",
"maintenance": "Maintenance",
"bootstrap": "Bootstrap Ansible",
}
# === Actions valides ===
VALID_ACTIONS = list(ACTION_DISPLAY_NAMES.keys())
# === Emojis pour les statuts de tâches ===
TASK_STATUS_EMOJIS: Dict[str, str] = {
TaskStatus.COMPLETED: "✅",
TaskStatus.FAILED: "❌",
TaskStatus.RUNNING: "🔄",
TaskStatus.PENDING: "⏳",
TaskStatus.CANCELLED: "🚫",
}
# === Labels pour les types de source ===
SOURCE_TYPE_LABELS: Dict[str, str] = {
SourceType.SCHEDULED: "Planifié",
SourceType.MANUAL: "Manuel",
SourceType.ADHOC: "Ad-hoc",
}
# === Catégories ad-hoc par défaut ===
DEFAULT_ADHOC_CATEGORIES = [
{"name": "default", "description": "Commandes générales", "color": "#7c3aed", "icon": "fa-terminal"},
{"name": "diagnostic", "description": "Commandes de diagnostic", "color": "#10b981", "icon": "fa-stethoscope"},
{"name": "maintenance", "description": "Commandes de maintenance", "color": "#f59e0b", "icon": "fa-wrench"},
{"name": "deployment", "description": "Commandes de déploiement", "color": "#3b82f6", "icon": "fa-rocket"},
]
# === Timeouts par défaut ===
DEFAULT_ADHOC_TIMEOUT = 60
DEFAULT_SCHEDULE_TIMEOUT = 3600
SSH_CONNECT_TIMEOUT = 10
# === Limites de pagination ===
DEFAULT_PAGE_LIMIT = 50
MAX_PAGE_LIMIT = 1000
+212
View File
@@ -0,0 +1,212 @@
"""
Dépendances FastAPI pour l'injection de dépendances.
Centralise toutes les dépendances communes utilisées dans les routes.
"""
from typing import AsyncGenerator, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.exceptions import AuthenticationException
from app.models.database import get_db as get_db_session
# === Schémas de sécurité ===
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
# === Dépendance: Session de base de données ===
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Fournit une session de base de données async.
Utilisée comme dépendance dans les endpoints:
```python
@app.get("/api/example")
async def example(db: AsyncSession = Depends(get_db)):
...
```
"""
async for session in get_db_session():
yield session
# === Dépendance: Vérification clé API ===
async def verify_api_key(
api_key: Optional[str] = Depends(api_key_header),
token: Optional[str] = Depends(oauth2_scheme),
) -> bool:
"""
Vérifie l'authentification par clé API ou JWT Bearer token.
Raises:
HTTPException 401 si non authentifié
Returns:
True si authentifié
"""
# Vérifier la clé API
if api_key and api_key == settings.api_key:
return True
# Vérifier le token JWT
if token:
try:
from app.services.auth_service import decode_token
token_data = decode_token(token)
if token_data:
return True
except Exception:
pass
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise (clé API ou JWT)",
headers={"WWW-Authenticate": "Bearer"},
)
# === Dépendance: Vérification JWT (optionnelle) ===
async def get_current_user_optional(
token: Optional[str] = Depends(oauth2_scheme),
api_key: Optional[str] = Depends(api_key_header),
) -> Optional[dict]:
"""
Vérifie l'authentification par JWT ou clé API (optionnel).
Returns:
Dictionnaire utilisateur si authentifié, None sinon
"""
# Vérifier d'abord la clé API (compatibilité legacy)
if api_key and api_key == settings.api_key:
return {"type": "api_key", "authenticated": True}
# Vérifier le token JWT
if token:
try:
from app.services.auth_service import decode_token
token_data = decode_token(token)
if token_data:
return {
"type": "jwt",
"authenticated": True,
"user_id": token_data.user_id,
"username": token_data.username,
"role": token_data.role,
}
except Exception:
pass
return None
async def get_current_user(
user: Optional[dict] = Depends(get_current_user_optional)
) -> dict:
"""
Vérifie l'authentification par JWT ou clé API (obligatoire).
Raises:
HTTPException 401 si non authentifié
Returns:
Dictionnaire utilisateur
"""
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise",
headers={"WWW-Authenticate": "Bearer"},
)
return user
# === Dépendance: Vérification rôle admin ===
async def require_admin(
user: dict = Depends(get_current_user)
) -> dict:
"""
Vérifie que l'utilisateur est admin.
Raises:
HTTPException 403 si l'utilisateur n'est pas admin
Returns:
Dictionnaire utilisateur
"""
# La clé API a tous les droits
if user.get("type") == "api_key":
return user
# Vérifier le rôle dans le payload JWT
payload = user.get("payload", {})
role = payload.get("role", "viewer")
if role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Droits administrateur requis"
)
return user
# === Dépendance combinée: Auth + DB ===
class AuthenticatedDB:
"""Conteneur pour la session DB et l'info utilisateur."""
def __init__(self, db: AsyncSession, user: dict):
self.db = db
self.user = user
async def get_authenticated_db(
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user)
) -> AuthenticatedDB:
"""
Fournit une session DB authentifiée.
Returns:
AuthenticatedDB avec session et info utilisateur
"""
return AuthenticatedDB(db=db, user=user)
# === Dépendance: Pagination ===
class PaginationParams:
"""Paramètres de pagination communs."""
def __init__(
self,
limit: int = 50,
offset: int = 0
):
from app.core.constants import MAX_PAGE_LIMIT
self.limit = min(limit, MAX_PAGE_LIMIT)
self.offset = max(offset, 0)
def get_pagination(
limit: int = 50,
offset: int = 0
) -> PaginationParams:
"""
Extrait les paramètres de pagination des query params.
Returns:
PaginationParams avec limit et offset validés
"""
return PaginationParams(limit=limit, offset=offset)
+258
View File
@@ -0,0 +1,258 @@
"""
Exceptions personnalisées de l'application.
Centralise la gestion des erreurs avec des exceptions typées.
"""
from typing import Any, Dict, Optional
class HomelabException(Exception):
"""Exception de base pour l'application Homelab."""
def __init__(
self,
message: str,
status_code: int = 500,
details: Optional[Dict[str, Any]] = None
):
self.message = message
self.status_code = status_code
self.details = details or {}
super().__init__(self.message)
def to_dict(self) -> Dict[str, Any]:
"""Convertit l'exception en dictionnaire pour la réponse API."""
return {
"error": self.__class__.__name__,
"message": self.message,
"details": self.details,
}
# === Exceptions 404 Not Found ===
class NotFoundException(HomelabException):
"""Exception de base pour les ressources non trouvées."""
def __init__(self, resource_type: str, identifier: str):
super().__init__(
message=f"{resource_type} '{identifier}' non trouvé(e)",
status_code=404,
details={"resource_type": resource_type, "identifier": identifier}
)
class HostNotFoundException(NotFoundException):
"""Hôte non trouvé."""
def __init__(self, identifier: str):
super().__init__("Hôte", identifier)
class TaskNotFoundException(NotFoundException):
"""Tâche non trouvée."""
def __init__(self, identifier: str):
super().__init__("Tâche", identifier)
class ScheduleNotFoundException(NotFoundException):
"""Schedule non trouvé."""
def __init__(self, identifier: str):
super().__init__("Schedule", identifier)
class PlaybookNotFoundException(NotFoundException):
"""Playbook non trouvé."""
def __init__(self, identifier: str):
super().__init__("Playbook", identifier)
class GroupNotFoundException(NotFoundException):
"""Groupe non trouvé."""
def __init__(self, identifier: str):
super().__init__("Groupe", identifier)
class LogNotFoundException(NotFoundException):
"""Log non trouvé."""
def __init__(self, identifier: str):
super().__init__("Log", identifier)
# === Exceptions 400 Bad Request ===
class ValidationException(HomelabException):
"""Erreur de validation des données."""
def __init__(self, message: str, field: Optional[str] = None):
details = {"field": field} if field else {}
super().__init__(
message=message,
status_code=400,
details=details
)
class DuplicateResourceException(HomelabException):
"""Ressource déjà existante."""
def __init__(self, resource_type: str, identifier: str):
super().__init__(
message=f"{resource_type} '{identifier}' existe déjà",
status_code=400,
details={"resource_type": resource_type, "identifier": identifier}
)
class InvalidOperationException(HomelabException):
"""Opération non valide dans l'état actuel."""
def __init__(self, message: str, current_state: Optional[str] = None):
details = {"current_state": current_state} if current_state else {}
super().__init__(
message=message,
status_code=400,
details=details
)
class IncompatiblePlaybookException(HomelabException):
"""Playbook incompatible avec la cible."""
def __init__(self, playbook: str, target: str, playbook_hosts: str):
super().__init__(
message=f"Le playbook '{playbook}' (hosts: {playbook_hosts}) n'est pas compatible avec la cible '{target}'",
status_code=400,
details={
"playbook": playbook,
"target": target,
"playbook_hosts": playbook_hosts,
}
)
# === Exceptions 401/403 Auth ===
class AuthenticationException(HomelabException):
"""Erreur d'authentification."""
def __init__(self, message: str = "Authentification requise"):
super().__init__(message=message, status_code=401)
class AuthorizationException(HomelabException):
"""Erreur d'autorisation."""
def __init__(self, message: str = "Accès non autorisé"):
super().__init__(message=message, status_code=403)
# === Exceptions 500 Server Error ===
class AnsibleExecutionException(HomelabException):
"""Erreur lors de l'exécution Ansible."""
def __init__(
self,
message: str,
return_code: int = -1,
stdout: str = "",
stderr: str = ""
):
super().__init__(
message=message,
status_code=500,
details={
"return_code": return_code,
"stdout": stdout,
"stderr": stderr,
}
)
class BootstrapException(HomelabException):
"""Erreur lors du bootstrap d'un hôte."""
def __init__(
self,
host: str,
message: str,
return_code: int = -1,
stdout: str = "",
stderr: str = ""
):
super().__init__(
message=f"Échec bootstrap pour {host}: {message}",
status_code=500,
details={
"host": host,
"return_code": return_code,
"stdout": stdout,
"stderr": stderr,
}
)
class SSHConnectionException(HomelabException):
"""Erreur de connexion SSH."""
def __init__(self, host: str, message: str):
super().__init__(
message=f"Connexion SSH échouée vers {host}: {message}",
status_code=500,
details={"host": host}
)
class DatabaseException(HomelabException):
"""Erreur de base de données."""
def __init__(self, message: str, operation: Optional[str] = None):
details = {"operation": operation} if operation else {}
super().__init__(
message=f"Erreur base de données: {message}",
status_code=500,
details=details
)
class SchedulerException(HomelabException):
"""Erreur du service de planification."""
def __init__(self, message: str, schedule_id: Optional[str] = None):
details = {"schedule_id": schedule_id} if schedule_id else {}
super().__init__(
message=f"Erreur scheduler: {message}",
status_code=500,
details=details
)
class NotificationException(HomelabException):
"""Erreur lors de l'envoi de notification."""
def __init__(self, message: str, topic: Optional[str] = None):
details = {"topic": topic} if topic else {}
super().__init__(
message=f"Erreur notification: {message}",
status_code=500,
details=details
)
class FileOperationException(HomelabException):
"""Erreur lors d'une opération sur fichier."""
def __init__(self, message: str, path: Optional[str] = None):
details = {"path": path} if path else {}
super().__init__(
message=message,
status_code=500,
details=details
)