refactorisation pour correction de sécurité
Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
This commit is contained in:
parent
21d99a0f48
commit
88742892d0
69
.env.example
Normal file
69
.env.example
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# ======================================================
|
||||||
|
# Homelab Automation Dashboard — Environment Variables
|
||||||
|
# ======================================================
|
||||||
|
# Copy this file to .env and fill in the values.
|
||||||
|
# DO NOT commit the .env file with real credentials!
|
||||||
|
|
||||||
|
# --- General ---
|
||||||
|
TZ="America/Montreal"
|
||||||
|
DEBUG_MODE=NO
|
||||||
|
|
||||||
|
# --- API Authentication ---
|
||||||
|
# REQUIRED: Set a strong, unique API key
|
||||||
|
API_KEY=CHANGE_ME_TO_A_STRONG_API_KEY
|
||||||
|
|
||||||
|
# --- JWT Authentication ---
|
||||||
|
# REQUIRED: Set a strong secret key (min 32 chars)
|
||||||
|
JWT_SECRET_KEY=CHANGE_ME_TO_A_STRONG_SECRET_KEY_MIN_32_CHARS
|
||||||
|
JWT_EXPIRE_MINUTES=60
|
||||||
|
|
||||||
|
# --- Database ---
|
||||||
|
DATABASE_URL=sqlite+aiosqlite:///./data/homelab.db
|
||||||
|
DB_PATH=./data/homelab.db
|
||||||
|
# DB_ENGINE=mysql
|
||||||
|
# MYSQL_HOST=mysql
|
||||||
|
# MYSQL_USER=homelab
|
||||||
|
# MYSQL_PASSWORD=CHANGE_ME
|
||||||
|
# DB_AUTO_MIGRATE=true
|
||||||
|
|
||||||
|
# --- Logging ---
|
||||||
|
LOGS_DIR=./logs/Server_log
|
||||||
|
DIR_LOGS_TASKS=./logs/tasks_logs
|
||||||
|
|
||||||
|
# --- Ansible ---
|
||||||
|
ANSIBLE_INVENTORY=./ansible/inventory
|
||||||
|
ANSIBLE_PLAYBOOKS=./ansible/playbooks
|
||||||
|
ANSIBLE_GROUP_VARS=./ansible/inventory/group_vars
|
||||||
|
# ANSIBLE_CONFIG=/path/to/ansible.cfg
|
||||||
|
|
||||||
|
# --- SSH ---
|
||||||
|
SSH_USER=automation
|
||||||
|
SSH_REMOTE_USER=automation
|
||||||
|
SSH_KEY_DIR=~/.ssh
|
||||||
|
SSH_KEY_PATH=~/.ssh/id_automation_ansible
|
||||||
|
|
||||||
|
# --- CORS ---
|
||||||
|
# Comma-separated list of allowed origins (no wildcard in production!)
|
||||||
|
CORS_ORIGINS=http://localhost:3000,http://localhost:8008
|
||||||
|
|
||||||
|
# --- Notifications (ntfy) ---
|
||||||
|
NTFY_BASE_URL=https://ntfy.sh
|
||||||
|
NTFY_DEFAULT_TOPIC=homelab-events
|
||||||
|
NTFY_ENABLED=true
|
||||||
|
NTFY_MSG_TYPE=ERR
|
||||||
|
NTFY_TIMEOUT=5
|
||||||
|
# NTFY_USERNAME=
|
||||||
|
# NTFY_PASSWORD=CHANGE_ME
|
||||||
|
# NTFY_TOKEN=CHANGE_ME
|
||||||
|
|
||||||
|
# --- Terminal SSH Web ---
|
||||||
|
TERMINAL_SESSION_TTL_MINUTES=30
|
||||||
|
TERMINAL_TTYD_INTERFACE=eth0
|
||||||
|
TERMINAL_MAX_SESSIONS_PER_USER=3
|
||||||
|
TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS=120
|
||||||
|
TERMINAL_HEARTBEAT_INTERVAL_SECONDS=15
|
||||||
|
TERMINAL_GC_INTERVAL_SECONDS=30
|
||||||
|
TERMINAL_PORT_RANGE_START=7682
|
||||||
|
TERMINAL_PORT_RANGE_END=7699
|
||||||
|
TERMINAL_SSH_USER=automation
|
||||||
|
TERMINAL_COMMAND_RETENTION_DAYS=30
|
||||||
BIN
ansible.zip
Normal file
BIN
ansible.zip
Normal file
Binary file not shown.
6585
app/app_optimized.py
6585
app/app_optimized.py
File diff suppressed because it is too large
Load Diff
@ -1106,7 +1106,7 @@ const containersPage = {
|
|||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
const diff = Math.floor((now - d) / 1000);
|
const diff = Math.floor((now - d) / 1000);
|
||||||
|
|
||||||
if (diff < 60) return 'À l\'instant';
|
if (diff < 60) return 'À l instant';
|
||||||
if (diff < 3600) return `il y a ${Math.floor(diff / 60)} min`;
|
if (diff < 3600) return `il y a ${Math.floor(diff / 60)} min`;
|
||||||
if (diff < 86400) return `il y a ${Math.floor(diff / 3600)} h`;
|
if (diff < 86400) return `il y a ${Math.floor(diff / 3600)} h`;
|
||||||
return `il y a ${Math.floor(diff / 86400)} j`;
|
return `il y a ${Math.floor(diff / 86400)} j`;
|
||||||
@ -1129,7 +1129,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Listen for page navigation
|
// Listen for page navigation
|
||||||
const originalNavigateTo = window.navigateTo;
|
const originalNavigateTo = window.navigateTo;
|
||||||
if (originalNavigateTo) {
|
if (originalNavigateTo) {
|
||||||
window.navigateTo = function(pageName) {
|
window.navigateTo = function (pageName) {
|
||||||
originalNavigateTo(pageName);
|
originalNavigateTo(pageName);
|
||||||
if (pageName === 'docker-containers') {
|
if (pageName === 'docker-containers') {
|
||||||
containersPage.init();
|
containersPage.init();
|
||||||
|
|||||||
@ -60,14 +60,14 @@ class Settings(BaseSettings):
|
|||||||
ssh_remote_user: str = Field(default_factory=lambda: os.environ.get("SSH_REMOTE_USER", "root"))
|
ssh_remote_user: str = Field(default_factory=lambda: os.environ.get("SSH_REMOTE_USER", "root"))
|
||||||
|
|
||||||
# === API ===
|
# === API ===
|
||||||
api_key: str = Field(default_factory=lambda: os.environ.get("API_KEY", "dev-key-12345"))
|
api_key: str = Field(default_factory=lambda: os.environ.get("API_KEY", ""))
|
||||||
api_title: str = "Homelab Automation Dashboard API"
|
api_title: str = "Homelab Automation Dashboard API"
|
||||||
api_version: str = "1.0.0"
|
api_version: str = "1.0.0"
|
||||||
api_description: str = "API REST moderne pour la gestion automatique d'homelab"
|
api_description: str = "API REST moderne pour la gestion automatique d'homelab"
|
||||||
|
|
||||||
# === JWT Authentication ===
|
# === JWT Authentication ===
|
||||||
jwt_secret_key: str = Field(
|
jwt_secret_key: str = Field(
|
||||||
default_factory=lambda: os.environ.get("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
|
default_factory=lambda: os.environ.get("JWT_SECRET_KEY", "")
|
||||||
)
|
)
|
||||||
jwt_expire_minutes: int = Field(
|
jwt_expire_minutes: int = Field(
|
||||||
default_factory=lambda: int(os.environ.get("JWT_EXPIRE_MINUTES", "1440"))
|
default_factory=lambda: int(os.environ.get("JWT_EXPIRE_MINUTES", "1440"))
|
||||||
@ -85,7 +85,13 @@ class Settings(BaseSettings):
|
|||||||
return f"sqlite+aiosqlite:///{self.db_path}"
|
return f"sqlite+aiosqlite:///{self.db_path}"
|
||||||
|
|
||||||
# === CORS ===
|
# === CORS ===
|
||||||
cors_origins: list = Field(default=["*"])
|
cors_origins: list = Field(
|
||||||
|
default_factory=lambda: [
|
||||||
|
o.strip() for o in os.environ.get(
|
||||||
|
"CORS_ORIGINS", "http://localhost:3000,http://localhost:8008"
|
||||||
|
).split(",") if o.strip()
|
||||||
|
]
|
||||||
|
)
|
||||||
cors_allow_credentials: bool = True
|
cors_allow_credentials: bool = True
|
||||||
cors_allow_methods: list = Field(default=["*"])
|
cors_allow_methods: list = Field(default=["*"])
|
||||||
cors_allow_headers: list = Field(default=["*"])
|
cors_allow_headers: list = Field(default=["*"])
|
||||||
|
|||||||
@ -157,9 +157,8 @@ async def require_admin(
|
|||||||
if user.get("type") == "api_key":
|
if user.get("type") == "api_key":
|
||||||
return user
|
return user
|
||||||
|
|
||||||
# Vérifier le rôle dans le payload JWT
|
# Vérifier le rôle directement dans le dict utilisateur (pas dans "payload")
|
||||||
payload = user.get("payload", {})
|
role = user.get("role", "viewer")
|
||||||
role = payload.get("role", "viewer")
|
|
||||||
|
|
||||||
if role != "admin":
|
if role != "admin":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@ -5,11 +5,13 @@ Ce module contient la fonction create_app() qui configure et retourne
|
|||||||
une instance FastAPI prête à l'emploi.
|
une instance FastAPI prête à l'emploi.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import HTMLResponse
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
from fastapi.responses import FileResponse
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models.database import init_db, async_session_maker
|
from app.models.database import init_db, async_session_maker
|
||||||
@ -40,6 +42,11 @@ def create_app() -> FastAPI:
|
|||||||
allow_headers=settings.cors_allow_headers,
|
allow_headers=settings.cors_allow_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
# Monter les fichiers statiques (main.js, etc.)
|
# Monter les fichiers statiques (main.js, etc.)
|
||||||
app.mount("/static", StaticFiles(directory=settings.base_dir, html=False), name="static")
|
app.mount("/static", StaticFiles(directory=settings.base_dir, html=False), name="static")
|
||||||
|
|
||||||
|
|||||||
37
app/main.js
37
app/main.js
@ -2015,7 +2015,7 @@ class DashboardManager {
|
|||||||
const commIndicator = `
|
const commIndicator = `
|
||||||
<div class="flex items-center gap-1" title="${commQuality.tooltip}">
|
<div class="flex items-center gap-1" title="${commQuality.tooltip}">
|
||||||
<div class="flex items-center gap-0.5">
|
<div class="flex items-center gap-0.5">
|
||||||
${[1,2,3,4,5].map(i => `
|
${[1, 2, 3, 4, 5].map(i => `
|
||||||
<div class="w-1 rounded-sm transition-all ${i <= commQuality.level ? commQuality.colorClass : 'bg-gray-600'}"
|
<div class="w-1 rounded-sm transition-all ${i <= commQuality.level ? commQuality.colorClass : 'bg-gray-600'}"
|
||||||
style="height: ${4 + i * 2}px;"></div>
|
style="height: ${4 + i * 2}px;"></div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
@ -4443,8 +4443,7 @@ class DashboardManager {
|
|||||||
|
|
||||||
// Badge de catégorie
|
// Badge de catégorie
|
||||||
const categoryBadge = log.category
|
const categoryBadge = log.category
|
||||||
? `<span class="px-2 py-0.5 rounded text-xs cursor-pointer hover:opacity-80 transition-opacity ${
|
? `<span class="px-2 py-0.5 rounded text-xs cursor-pointer hover:opacity-80 transition-opacity ${log.category === 'Playbook' ? 'bg-purple-600/30 text-purple-300 border border-purple-500/50' :
|
||||||
log.category === 'Playbook' ? 'bg-purple-600/30 text-purple-300 border border-purple-500/50' :
|
|
||||||
log.category === 'Ad-hoc' ? 'bg-blue-600/30 text-blue-300 border border-blue-500/50' :
|
log.category === 'Ad-hoc' ? 'bg-blue-600/30 text-blue-300 border border-blue-500/50' :
|
||||||
'bg-gray-600/30 text-gray-300 border border-gray-500/50'
|
'bg-gray-600/30 text-gray-300 border border-gray-500/50'
|
||||||
}" onclick="event.stopPropagation(); dashboard.filterByCategory('${this.escapeHtml(log.category)}')" title="Filtrer par catégorie">
|
}" onclick="event.stopPropagation(); dashboard.filterByCategory('${this.escapeHtml(log.category)}')" title="Filtrer par catégorie">
|
||||||
@ -5600,11 +5599,11 @@ class DashboardManager {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1 h-1.5 rounded-full overflow-hidden bg-gray-800">
|
<div class="flex gap-1 h-1.5 rounded-full overflow-hidden bg-gray-800">
|
||||||
${stats.ok > 0 ? `<div class="bg-green-500" style="width: ${(stats.ok/total)*100}%" title="OK: ${stats.ok}"></div>` : ''}
|
${stats.ok > 0 ? `<div class="bg-green-500" style="width: ${(stats.ok / total) * 100}%" title="OK: ${stats.ok}"></div>` : ''}
|
||||||
${stats.changed > 0 ? `<div class="bg-yellow-500" style="width: ${(stats.changed/total)*100}%" title="Changed: ${stats.changed}"></div>` : ''}
|
${stats.changed > 0 ? `<div class="bg-yellow-500" style="width: ${(stats.changed / total) * 100}%" title="Changed: ${stats.changed}"></div>` : ''}
|
||||||
${stats.skipped > 0 ? `<div class="bg-gray-500" style="width: ${(stats.skipped/total)*100}%" title="Skipped: ${stats.skipped}"></div>` : ''}
|
${stats.skipped > 0 ? `<div class="bg-gray-500" style="width: ${(stats.skipped / total) * 100}%" title="Skipped: ${stats.skipped}"></div>` : ''}
|
||||||
${stats.failed > 0 ? `<div class="bg-red-500" style="width: ${(stats.failed/total)*100}%" title="Failed: ${stats.failed}"></div>` : ''}
|
${stats.failed > 0 ? `<div class="bg-red-500" style="width: ${(stats.failed / total) * 100}%" title="Failed: ${stats.failed}"></div>` : ''}
|
||||||
${stats.unreachable > 0 ? `<div class="bg-orange-500" style="width: ${(stats.unreachable/total)*100}%" title="Unreachable: ${stats.unreachable}"></div>` : ''}
|
${stats.unreachable > 0 ? `<div class="bg-orange-500" style="width: ${(stats.unreachable / total) * 100}%" title="Unreachable: ${stats.unreachable}"></div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between mt-2 text-[10px] text-gray-500">
|
<div class="flex justify-between mt-2 text-[10px] text-gray-500">
|
||||||
<span><span class="text-green-400">${stats.ok}</span> ok</span>
|
<span><span class="text-green-400">${stats.ok}</span> ok</span>
|
||||||
@ -5656,7 +5655,7 @@ class DashboardManager {
|
|||||||
|
|
||||||
const hostResultsHtml = task.hostResults.map(result => {
|
const hostResultsHtml = task.hostResults.map(result => {
|
||||||
let resultIcon, resultColor, resultBg;
|
let resultIcon, resultColor, resultBg;
|
||||||
switch(result.status) {
|
switch (result.status) {
|
||||||
case 'ok':
|
case 'ok':
|
||||||
resultIcon = 'fa-check'; resultColor = 'text-green-400'; resultBg = 'bg-green-900/30';
|
resultIcon = 'fa-check'; resultColor = 'text-green-400'; resultBg = 'bg-green-900/30';
|
||||||
break;
|
break;
|
||||||
@ -5853,7 +5852,7 @@ class DashboardManager {
|
|||||||
|
|
||||||
const tasksHtml = hostData.taskResults.map(result => {
|
const tasksHtml = hostData.taskResults.map(result => {
|
||||||
let statusIcon, statusColor;
|
let statusIcon, statusColor;
|
||||||
switch(result.status) {
|
switch (result.status) {
|
||||||
case 'ok': statusIcon = 'fa-check'; statusColor = 'text-green-400'; break;
|
case 'ok': statusIcon = 'fa-check'; statusColor = 'text-green-400'; break;
|
||||||
case 'changed': statusIcon = 'fa-exchange-alt'; statusColor = 'text-yellow-400'; break;
|
case 'changed': statusIcon = 'fa-exchange-alt'; statusColor = 'text-yellow-400'; break;
|
||||||
case 'failed': case 'fatal': statusIcon = 'fa-times'; statusColor = 'text-red-400'; break;
|
case 'failed': case 'fatal': statusIcon = 'fa-times'; statusColor = 'text-red-400'; break;
|
||||||
@ -10352,7 +10351,7 @@ class DashboardManager {
|
|||||||
if (rec.type === 'daily') {
|
if (rec.type === 'daily') {
|
||||||
recurrenceText = `Tous les jours à ${rec.time}`;
|
recurrenceText = `Tous les jours à ${rec.time}`;
|
||||||
} else if (rec.type === 'weekly') {
|
} else if (rec.type === 'weekly') {
|
||||||
const days = (rec.days || []).map(d => ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'][d-1]).join(', ');
|
const days = (rec.days || []).map(d => ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'][d - 1]).join(', ');
|
||||||
recurrenceText = `Chaque ${days} à ${rec.time}`;
|
recurrenceText = `Chaque ${days} à ${rec.time}`;
|
||||||
} else if (rec.type === 'monthly') {
|
} else if (rec.type === 'monthly') {
|
||||||
recurrenceText = `Le ${rec.day_of_month || 1} de chaque mois à ${rec.time}`;
|
recurrenceText = `Le ${rec.day_of_month || 1} de chaque mois à ${rec.time}`;
|
||||||
@ -10905,8 +10904,8 @@ class DashboardManager {
|
|||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
${['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map((day, i) => `
|
${['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map((day, i) => `
|
||||||
<label class="flex items-center gap-1 px-3 py-1.5 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700">
|
<label class="flex items-center gap-1 px-3 py-1.5 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700">
|
||||||
<input type="checkbox" value="${i+1}" class="schedule-day-checkbox"
|
<input type="checkbox" value="${i + 1}" class="schedule-day-checkbox"
|
||||||
${daysChecked.includes(i+1) ? 'checked' : ''}>
|
${daysChecked.includes(i + 1) ? 'checked' : ''}>
|
||||||
<span class="text-sm">${day}</span>
|
<span class="text-sm">${day}</span>
|
||||||
</label>
|
</label>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
@ -11416,22 +11415,20 @@ class DashboardManager {
|
|||||||
message: msgStr,
|
message: msgStr,
|
||||||
source: 'ui'
|
source: 'ui'
|
||||||
})
|
})
|
||||||
}).catch(() => {});
|
}).catch(() => { });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
const notification = document.createElement('div');
|
const notification = document.createElement('div');
|
||||||
notification.className = `fixed top-20 right-6 z-50 p-4 rounded-lg shadow-lg transition-all duration-300 ${
|
notification.className = `fixed top-20 right-6 z-50 p-4 rounded-lg shadow-lg transition-all duration-300 ${type === 'success' ? 'bg-green-600' :
|
||||||
type === 'success' ? 'bg-green-600' :
|
|
||||||
type === 'warning' ? 'bg-yellow-600' :
|
type === 'warning' ? 'bg-yellow-600' :
|
||||||
type === 'error' ? 'bg-red-600' : 'bg-blue-600'
|
type === 'error' ? 'bg-red-600' : 'bg-blue-600'
|
||||||
} text-white`;
|
} text-white`;
|
||||||
notification.innerHTML = `
|
notification.innerHTML = `
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<i class="fas ${
|
<i class="fas ${type === 'success' ? 'fa-check-circle' :
|
||||||
type === 'success' ? 'fa-check-circle' :
|
|
||||||
type === 'warning' ? 'fa-exclamation-triangle' :
|
type === 'warning' ? 'fa-exclamation-triangle' :
|
||||||
type === 'error' ? 'fa-exclamation-circle' : 'fa-info-circle'
|
type === 'error' ? 'fa-exclamation-circle' : 'fa-info-circle'
|
||||||
}"></i>
|
}"></i>
|
||||||
@ -11720,6 +11717,8 @@ class DashboardManager {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to open terminal popout:', e);
|
console.error('Failed to open terminal popout:', e);
|
||||||
this.showNotification(`Erreur terminal: ${e.message}`, 'error');
|
this.showNotification(`Erreur terminal: ${e.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
this.terminalPopoutOpening = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -12704,7 +12703,7 @@ function closeModal() {
|
|||||||
dashboard.closeModal();
|
dashboard.closeModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.showCreateScheduleModal = function(prefilledPlaybook = null) {
|
window.showCreateScheduleModal = function (prefilledPlaybook = null) {
|
||||||
if (!window.dashboard) {
|
if (!window.dashboard) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,8 +19,11 @@ aiosqlite>=0.19.0
|
|||||||
pytest>=7.0.0
|
pytest>=7.0.0
|
||||||
pytest-asyncio>=0.21.0
|
pytest-asyncio>=0.21.0
|
||||||
# Authentication
|
# Authentication
|
||||||
python-jose[cryptography]>=3.3.0
|
PyJWT>=2.8.0
|
||||||
passlib[bcrypt]>=1.7.4
|
bcrypt>=4.0.0
|
||||||
reportlab>=4.0.0
|
reportlab>=4.0.0
|
||||||
pillow>=10.0.0
|
pillow>=10.0.0
|
||||||
asyncssh>=2.14.0
|
asyncssh>=2.14.0
|
||||||
|
slowapi>=0.1.9
|
||||||
|
cachetools>=5.3.0
|
||||||
|
jinja2>=3.1.0
|
||||||
@ -5,8 +5,10 @@ Routes API pour l'authentification JWT.
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.dependencies import get_db, get_current_user, get_current_user_optional
|
from app.core.dependencies import get_db, get_current_user, get_current_user_optional
|
||||||
@ -17,6 +19,7 @@ from app.schemas.auth import (
|
|||||||
from app.services import auth_service
|
from app.services import auth_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
@ -55,7 +58,9 @@ async def auth_status(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/setup")
|
@router.post("/setup")
|
||||||
|
@limiter.limit("3/minute")
|
||||||
async def setup_admin(
|
async def setup_admin(
|
||||||
|
request: Request,
|
||||||
user_data: UserCreate,
|
user_data: UserCreate,
|
||||||
db_session: AsyncSession = Depends(get_db)
|
db_session: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
@ -93,7 +98,9 @@ async def setup_admin(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
async def login_form(
|
async def login_form(
|
||||||
|
request: Request,
|
||||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
db_session: AsyncSession = Depends(get_db)
|
db_session: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
@ -129,7 +136,9 @@ async def login_form(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login/json", response_model=Token)
|
@router.post("/login/json", response_model=Token)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
async def login_json(
|
async def login_json(
|
||||||
|
request: Request,
|
||||||
credentials: LoginRequest,
|
credentials: LoginRequest,
|
||||||
db_session: AsyncSession = Depends(get_db)
|
db_session: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -7,10 +7,33 @@ from typing import Optional
|
|||||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_password_strength(password: str) -> str:
|
||||||
|
"""Shared password strength validator.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Minimum 8 characters
|
||||||
|
- At least 1 uppercase letter
|
||||||
|
- At least 1 lowercase letter
|
||||||
|
- At least 1 digit
|
||||||
|
- At least 1 special character
|
||||||
|
"""
|
||||||
|
if len(password) < 8:
|
||||||
|
raise ValueError("Le mot de passe doit contenir au moins 8 caractères")
|
||||||
|
if not any(c.isupper() for c in password):
|
||||||
|
raise ValueError("Le mot de passe doit contenir au moins une majuscule")
|
||||||
|
if not any(c.islower() for c in password):
|
||||||
|
raise ValueError("Le mot de passe doit contenir au moins une minuscule")
|
||||||
|
if not any(c.isdigit() for c in password):
|
||||||
|
raise ValueError("Le mot de passe doit contenir au moins un chiffre")
|
||||||
|
if not any(c in "!@#$%^&*()_+-=[]{}|;:',.<>?/~`" for c in password):
|
||||||
|
raise ValueError("Le mot de passe doit contenir au moins un caractère spécial")
|
||||||
|
return password
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
"""Request schema for user login."""
|
"""Request schema for user login."""
|
||||||
username: str = Field(..., min_length=3, max_length=50, description="Username")
|
username: str = Field(..., min_length=3, max_length=50, description="Username")
|
||||||
password: str = Field(..., min_length=6, description="Password")
|
password: str = Field(..., min_length=1, description="Password")
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
@ -39,15 +62,12 @@ class UserBase(BaseModel):
|
|||||||
|
|
||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
"""Schema for creating a new user."""
|
"""Schema for creating a new user."""
|
||||||
password: str = Field(..., min_length=6, max_length=128, description="Password (min 6 chars)")
|
password: str = Field(..., min_length=8, max_length=128, description="Password (min 8 chars, requires uppercase, lowercase, digit, special char)")
|
||||||
|
|
||||||
@field_validator('password')
|
@field_validator('password')
|
||||||
@classmethod
|
@classmethod
|
||||||
def password_strength(cls, v: str) -> str:
|
def password_strength(cls, v: str) -> str:
|
||||||
"""Validate password has minimum complexity."""
|
return _validate_password_strength(v)
|
||||||
if len(v) < 6:
|
|
||||||
raise ValueError('Password must be at least 6 characters')
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
@ -61,14 +81,12 @@ class UserUpdate(BaseModel):
|
|||||||
class PasswordChange(BaseModel):
|
class PasswordChange(BaseModel):
|
||||||
"""Schema for changing password."""
|
"""Schema for changing password."""
|
||||||
current_password: str = Field(..., description="Current password")
|
current_password: str = Field(..., description="Current password")
|
||||||
new_password: str = Field(..., min_length=6, max_length=128, description="New password")
|
new_password: str = Field(..., min_length=8, max_length=128, description="New password")
|
||||||
|
|
||||||
@field_validator('new_password')
|
@field_validator('new_password')
|
||||||
@classmethod
|
@classmethod
|
||||||
def password_strength(cls, v: str) -> str:
|
def password_strength(cls, v: str) -> str:
|
||||||
if len(v) < 6:
|
return _validate_password_strength(v)
|
||||||
raise ValueError('Password must be at least 6 characters')
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class UserOut(BaseModel):
|
class UserOut(BaseModel):
|
||||||
@ -90,16 +108,14 @@ class UserOut(BaseModel):
|
|||||||
class UserSetup(BaseModel):
|
class UserSetup(BaseModel):
|
||||||
"""Schema for initial admin setup (first user creation)."""
|
"""Schema for initial admin setup (first user creation)."""
|
||||||
username: str = Field(..., min_length=3, max_length=50)
|
username: str = Field(..., min_length=3, max_length=50)
|
||||||
password: str = Field(..., min_length=6, max_length=128)
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
email: Optional[EmailStr] = None
|
email: Optional[EmailStr] = None
|
||||||
display_name: Optional[str] = None
|
display_name: Optional[str] = None
|
||||||
|
|
||||||
@field_validator('password')
|
@field_validator('password')
|
||||||
@classmethod
|
@classmethod
|
||||||
def password_strength(cls, v: str) -> str:
|
def password_strength(cls, v: str) -> str:
|
||||||
if len(v) < 6:
|
return _validate_password_strength(v)
|
||||||
raise ValueError('Password must be at least 6 characters')
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class AuthStatus(BaseModel):
|
class AuthStatus(BaseModel):
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from time import perf_counter
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
from cachetools import TTLCache
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.schemas.host_api import AnsibleInventoryHost
|
from app.schemas.host_api import AnsibleInventoryHost
|
||||||
@ -28,27 +29,23 @@ class AnsibleService:
|
|||||||
self.ssh_key_path = ssh_key_path or settings.ssh_key_path
|
self.ssh_key_path = ssh_key_path or settings.ssh_key_path
|
||||||
self.ssh_user = ssh_user or settings.ssh_user
|
self.ssh_user = ssh_user or settings.ssh_user
|
||||||
|
|
||||||
# Cache
|
# Cache with automatic TTL eviction (replaces hand-rolled cache)
|
||||||
self._inventory_cache: Optional[Dict] = None
|
cache_ttl = settings.inventory_cache_ttl
|
||||||
self._inventory_cache_time: float = 0
|
self._inventory_cache: TTLCache = TTLCache(maxsize=16, ttl=cache_ttl)
|
||||||
self._playbooks_cache: Optional[List[PlaybookInfo]] = None
|
self._playbooks_cache: TTLCache = TTLCache(maxsize=16, ttl=cache_ttl)
|
||||||
self._playbooks_cache_time: float = 0
|
|
||||||
self._cache_ttl = settings.inventory_cache_ttl
|
|
||||||
|
|
||||||
def invalidate_cache(self):
|
def invalidate_cache(self):
|
||||||
"""Invalide les caches."""
|
"""Invalide les caches."""
|
||||||
self._inventory_cache = None
|
self._inventory_cache.clear()
|
||||||
self._playbooks_cache = None
|
self._playbooks_cache.clear()
|
||||||
|
|
||||||
# ===== PLAYBOOKS =====
|
# ===== PLAYBOOKS =====
|
||||||
|
|
||||||
def get_playbooks(self) -> List[Dict[str, Any]]:
|
def get_playbooks(self) -> List[Dict[str, Any]]:
|
||||||
"""Récupère la liste des playbooks disponibles."""
|
"""Récupère la liste des playbooks disponibles."""
|
||||||
import time
|
cache_key = "all"
|
||||||
current_time = time.time()
|
if cache_key in self._playbooks_cache:
|
||||||
|
return self._playbooks_cache[cache_key]
|
||||||
if self._playbooks_cache and (current_time - self._playbooks_cache_time) < self._cache_ttl:
|
|
||||||
return self._playbooks_cache
|
|
||||||
|
|
||||||
playbooks = []
|
playbooks = []
|
||||||
|
|
||||||
@ -78,8 +75,7 @@ class AnsibleService:
|
|||||||
if pb:
|
if pb:
|
||||||
playbooks.append(pb)
|
playbooks.append(pb)
|
||||||
|
|
||||||
self._playbooks_cache = playbooks
|
self._playbooks_cache[cache_key] = playbooks
|
||||||
self._playbooks_cache_time = current_time
|
|
||||||
return playbooks
|
return playbooks
|
||||||
|
|
||||||
def _parse_playbook_file(self, file_path: Path, category: str, subcategory: str) -> Optional[Dict[str, Any]]:
|
def _parse_playbook_file(self, file_path: Path, category: str, subcategory: str) -> Optional[Dict[str, Any]]:
|
||||||
@ -179,11 +175,9 @@ class AnsibleService:
|
|||||||
|
|
||||||
def load_inventory(self) -> Dict:
|
def load_inventory(self) -> Dict:
|
||||||
"""Charge l'inventaire Ansible depuis le fichier YAML."""
|
"""Charge l'inventaire Ansible depuis le fichier YAML."""
|
||||||
import time
|
cache_key = "inventory"
|
||||||
current_time = time.time()
|
if cache_key in self._inventory_cache:
|
||||||
|
return self._inventory_cache[cache_key]
|
||||||
if self._inventory_cache and (current_time - self._inventory_cache_time) < self._cache_ttl:
|
|
||||||
return self._inventory_cache
|
|
||||||
|
|
||||||
if not self.inventory_path.exists():
|
if not self.inventory_path.exists():
|
||||||
return {}
|
return {}
|
||||||
@ -191,8 +185,7 @@ class AnsibleService:
|
|||||||
try:
|
try:
|
||||||
with open(self.inventory_path, 'r', encoding='utf-8') as f:
|
with open(self.inventory_path, 'r', encoding='utf-8') as f:
|
||||||
inventory = yaml.safe_load(f) or {}
|
inventory = yaml.safe_load(f) or {}
|
||||||
self._inventory_cache = inventory
|
self._inventory_cache[cache_key] = inventory
|
||||||
self._inventory_cache_time = current_time
|
|
||||||
return inventory
|
return inventory
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
@ -204,8 +197,8 @@ class AnsibleService:
|
|||||||
with open(self.inventory_path, 'w', encoding='utf-8') as f:
|
with open(self.inventory_path, 'w', encoding='utf-8') as f:
|
||||||
yaml.dump(inventory, f, default_flow_style=False, allow_unicode=True)
|
yaml.dump(inventory, f, default_flow_style=False, allow_unicode=True)
|
||||||
|
|
||||||
# Invalider le cache
|
# Invalider le cache inventaire
|
||||||
self._inventory_cache = None
|
self._inventory_cache.clear()
|
||||||
|
|
||||||
def get_hosts_from_inventory(self, group_filter: str = None) -> List[AnsibleInventoryHost]:
|
def get_hosts_from_inventory(self, group_filter: str = None) -> List[AnsibleInventoryHost]:
|
||||||
"""Récupère les hôtes depuis l'inventaire Ansible."""
|
"""Récupère les hôtes depuis l'inventaire Ansible."""
|
||||||
|
|||||||
@ -11,15 +11,16 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from jose import JWTError, jwt
|
import jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.auth import TokenData
|
from app.schemas.auth import TokenData
|
||||||
|
|
||||||
# Configuration from environment variables
|
# Configuration from centralized settings (single source of truth)
|
||||||
SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "homelab-secret-key-change-in-production")
|
SECRET_KEY = settings.jwt_secret_key
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = settings.jwt_algorithm
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("JWT_EXPIRE_MINUTES", "1440")) # 24 hours default
|
ACCESS_TOKEN_EXPIRE_MINUTES = settings.jwt_expire_minutes
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
@ -83,7 +84,7 @@ class AuthService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return TokenData(username=username, user_id=user_id, role=role)
|
return TokenData(username=username, user_id=user_id, role=role)
|
||||||
except JWTError:
|
except (jwt.PyJWTError, jwt.ExpiredSignatureError, jwt.DecodeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
325
app/templates/terminal/connect.html
Normal file
325
app/templates/terminal/connect.html
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ safe_title }}</title>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: #0f0f1a;
|
||||||
|
color: #e5e7eb;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.terminal-header {
|
||||||
|
background: #1a1a2e;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #374151;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.terminal-header .host-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.terminal-header .host-name { font-weight: 600; color: #fff; }
|
||||||
|
.terminal-header .host-ip { color: #9ca3af; font-size: 0.875rem; }
|
||||||
|
.terminal-header .status-badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.terminal-header .status-badge.online {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
.terminal-header .session-timer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.terminal-header .session-timer.warning { color: #fbbf24; }
|
||||||
|
.terminal-header .session-timer.critical { color: #ef4444; }
|
||||||
|
.terminal-header .actions { display: flex; gap: 0.5rem; }
|
||||||
|
.terminal-header .btn {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.terminal-header .btn-secondary { background: #374151; color: #e5e7eb; }
|
||||||
|
.terminal-header .btn-secondary:hover { background: #4b5563; }
|
||||||
|
.terminal-header .btn-secondary.active { background: #4f46e5; color: #fff; }
|
||||||
|
.terminal-header .btn-danger { background: #dc2626; color: #fff; }
|
||||||
|
.terminal-header .btn-danger:hover { background: #b91c1c; }
|
||||||
|
.terminal-container { flex: 1; position: relative; overflow: hidden; }
|
||||||
|
.terminal-container iframe { width: 100%; height: 100%; border: none; background: #000; }
|
||||||
|
.terminal-loading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #0f0f1a;
|
||||||
|
}
|
||||||
|
.terminal-loading .spinner {
|
||||||
|
width: 48px; height: 48px;
|
||||||
|
border: 3px solid #374151;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.terminal-loading .loading-text { margin-top: 1rem; color: #9ca3af; }
|
||||||
|
.pwa-hint {
|
||||||
|
background: #1e3a5f;
|
||||||
|
color: #93c5fd;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.pwa-hint button {
|
||||||
|
background: none; border: none; color: #60a5fa; cursor: pointer; font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
body.embed #pwaHint { display: none !important; }
|
||||||
|
body.embed .terminal-header { display: none !important; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
/* History Panel */
|
||||||
|
.terminal-history-panel {
|
||||||
|
position: absolute; top: 0; left: 0; right: 0;
|
||||||
|
max-height: 0; overflow: hidden;
|
||||||
|
background: #1e1e2e; border-bottom: 1px solid #374151;
|
||||||
|
display: flex; flex-direction: column; z-index: 50;
|
||||||
|
box-shadow: 0 5px 25px rgba(0,0,0,0.5);
|
||||||
|
opacity: 0;
|
||||||
|
transition: max-height 0.3s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.terminal-history-panel.open { max-height: 350px; opacity: 1; }
|
||||||
|
.terminal-history-header {
|
||||||
|
padding: 0.75rem; background: #151520; border-bottom: 1px solid #374151; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.terminal-history-search {
|
||||||
|
position: relative; display: flex; align-items: center;
|
||||||
|
background: #0f0f1a; border: 1px solid #374151;
|
||||||
|
border-radius: 0.375rem; padding: 0.5rem 0.75rem; transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.terminal-history-search:focus-within { border-color: #7c3aed; }
|
||||||
|
.terminal-history-search i { color: #6b7280; font-size: 0.875rem; margin-right: 0.5rem; }
|
||||||
|
.terminal-history-search input {
|
||||||
|
flex: 1; background: transparent; border: none; color: #e5e7eb;
|
||||||
|
font-size: 0.875rem; outline: none;
|
||||||
|
}
|
||||||
|
.terminal-history-search input::placeholder { color: #6b7280; }
|
||||||
|
.terminal-history-clear-search {
|
||||||
|
background: none; border: none; color: #6b7280; cursor: pointer;
|
||||||
|
padding: 0.25rem; font-size: 0.75rem; transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.terminal-history-clear-search:hover { color: #e5e7eb; }
|
||||||
|
.terminal-history-filters {
|
||||||
|
display: flex; align-items: center; gap: 0.75rem; margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.terminal-history-filter-select {
|
||||||
|
background: #0f0f1a; border: 1px solid #374151; border-radius: 0.375rem;
|
||||||
|
color: #e5e7eb; padding: 0.375rem 0.5rem; font-size: 0.75rem;
|
||||||
|
cursor: pointer; outline: none;
|
||||||
|
}
|
||||||
|
.terminal-history-filter-select:hover,
|
||||||
|
.terminal-history-filter-select:focus { border-color: #7c3aed; }
|
||||||
|
.terminal-history-scope {
|
||||||
|
display: flex; align-items: center; gap: 0.375rem;
|
||||||
|
font-size: 0.75rem; color: #9ca3af; cursor: pointer;
|
||||||
|
}
|
||||||
|
.terminal-history-scope input {
|
||||||
|
width: 0.875rem; height: 0.875rem; accent-color: #7c3aed; cursor: pointer;
|
||||||
|
}
|
||||||
|
.terminal-history-list {
|
||||||
|
flex: 1; overflow-y: auto; padding: 0.5rem;
|
||||||
|
scrollbar-width: thin; scrollbar-color: #374151 transparent;
|
||||||
|
}
|
||||||
|
.terminal-history-list::-webkit-scrollbar { width: 6px; }
|
||||||
|
.terminal-history-list::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.terminal-history-list::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
|
||||||
|
.terminal-history-item {
|
||||||
|
display: flex; align-items: center; padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.375rem; cursor: pointer; transition: background 0.15s;
|
||||||
|
gap: 0.75rem; border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.terminal-history-item:hover { background: rgba(124, 58, 237, 0.1); }
|
||||||
|
.terminal-history-item.selected {
|
||||||
|
background: rgba(124, 58, 237, 0.2); border-color: rgba(124, 58, 237, 0.4);
|
||||||
|
}
|
||||||
|
.terminal-history-cmd { flex: 1; min-width: 0; }
|
||||||
|
.terminal-history-cmd code {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 0.8125rem; color: #a5b4fc;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block;
|
||||||
|
}
|
||||||
|
.terminal-history-cmd code mark {
|
||||||
|
background: rgba(251, 191, 36, 0.3); color: #fbbf24;
|
||||||
|
padding: 0 0.125rem; border-radius: 2px;
|
||||||
|
}
|
||||||
|
.terminal-history-meta { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||||
|
.terminal-history-time { font-size: 0.6875rem; color: #6b7280; }
|
||||||
|
.terminal-history-count {
|
||||||
|
font-size: 0.625rem; color: #7c3aed;
|
||||||
|
background: rgba(124, 58, 237, 0.2); padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 9999px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.terminal-history-actions-inline {
|
||||||
|
display: flex; gap: 0.25rem; opacity: 0; transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.terminal-history-item:hover .terminal-history-actions-inline,
|
||||||
|
.terminal-history-item.selected .terminal-history-actions-inline { opacity: 1; }
|
||||||
|
.terminal-history-action {
|
||||||
|
background: rgba(55, 65, 81, 0.5); border: none; color: #9ca3af;
|
||||||
|
padding: 0.375rem; border-radius: 0.25rem; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 0.75rem; transition: background 0.15s, color 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
.terminal-history-action:hover {
|
||||||
|
background: rgba(124, 58, 237, 0.3); color: #fff; transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.terminal-history-action-execute:hover {
|
||||||
|
background: rgba(34, 197, 94, 0.3); color: #4ade80;
|
||||||
|
}
|
||||||
|
.terminal-history-empty {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; padding: 2rem; color: #6b7280; gap: 0.5rem; text-align: center;
|
||||||
|
}
|
||||||
|
.terminal-history-empty i { font-size: 2rem; opacity: 0.5; }
|
||||||
|
.terminal-history-loading {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 2rem; color: #9ca3af; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.terminal-history-footer {
|
||||||
|
padding: 0.5rem 1rem; background: #151520;
|
||||||
|
border-top: 1px solid #374151; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.terminal-history-hint {
|
||||||
|
font-size: 0.6875rem; color: #6b7280;
|
||||||
|
display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.terminal-history-hint kbd {
|
||||||
|
background: #374151; color: #e5e7eb; padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem; font-family: inherit; font-size: 0.625rem; border: 1px solid #4b5563;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="{{ 'embed' if embed_mode else '' }}">
|
||||||
|
<div class="pwa-hint" id="pwaHint">
|
||||||
|
<span>💡 Pour une expérience sans barre d'outils : installez en PWA ou utilisez <code>chrome --app=URL</code></span>
|
||||||
|
<button onclick="dismissPwaHint()">✕ Fermer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="terminal-header">
|
||||||
|
<div class="host-info">
|
||||||
|
<span class="host-name">{{ safe_host_name }}</span>
|
||||||
|
<span class="host-ip">{{ safe_host_ip }}</span>
|
||||||
|
<span class="status-badge online">Connecté</span>
|
||||||
|
</div>
|
||||||
|
<div class="session-timer" id="sessionTimer">
|
||||||
|
<i class="fas fa-clock"></i>
|
||||||
|
<span id="timerDisplay">{{ timer_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-secondary" onclick="toggleHistory()" id="btnHistory" title="Historique (Ctrl+R)">
|
||||||
|
<i class="fas fa-history"></i> Historique
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="copySSHCommand()" title="Copier la commande SSH">
|
||||||
|
<i class="fas fa-copy"></i> SSH
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="reconnect()" title="Reconnecter">
|
||||||
|
<i class="fas fa-redo"></i> Reconnecter
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="goToDashboard()" title="Retour au Dashboard">
|
||||||
|
<i class="fas fa-arrow-left"></i> Dashboard
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger" onclick="closeSession()" title="Fermer la session">
|
||||||
|
<i class="fas fa-times"></i> Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="terminal-container">
|
||||||
|
<div class="terminal-loading" id="terminalLoading">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div class="loading-text">Connexion au terminal...</div>
|
||||||
|
<div style="margin-top:0.75rem; font-size:0.85rem; color:#9ca3af;">
|
||||||
|
session={{ session_id_short }}… · ttyd_port={{ ttyd_port }} · Debug: Ctrl+Shift+D
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<noscript>
|
||||||
|
<div style="position:absolute; top:14px; right:14px; z-index:9999; background:rgba(239,68,68,0.18); border:1px solid rgba(239,68,68,0.6); color:#fff; padding:10px 12px; border-radius:10px; font-size:12px; max-width:520px;">
|
||||||
|
JavaScript est désactivé: le terminal web ne peut pas se charger.
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
{% if debug_panel_html %}
|
||||||
|
{{ debug_panel_html | safe }}
|
||||||
|
{% endif %}
|
||||||
|
<iframe
|
||||||
|
id="terminalFrame"
|
||||||
|
src="about:blank"
|
||||||
|
allow="clipboard-read; clipboard-write; clipboard-write-text"
|
||||||
|
></iframe>
|
||||||
|
<div class="terminal-history-panel" id="terminalHistoryPanel" style="display: none;">
|
||||||
|
<div class="terminal-history-header">
|
||||||
|
<div class="terminal-history-search">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<input type="text"
|
||||||
|
id="terminalHistorySearch"
|
||||||
|
placeholder="Rechercher... (Ctrl+R)"
|
||||||
|
oninput="searchHistory(this.value)"
|
||||||
|
onkeydown="handleHistoryKeydown(event)"
|
||||||
|
autocomplete="off">
|
||||||
|
<button class="terminal-history-clear-search" onclick="clearHistorySearch()" title="Effacer">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-history-filters">
|
||||||
|
<select id="terminalHistoryTimeFilter" onchange="setHistoryTimeFilter(this.value)" class="terminal-history-filter-select">
|
||||||
|
<option value="all">Tout</option>
|
||||||
|
<option value="today">Aujourd'hui</option>
|
||||||
|
<option value="week">7 jours</option>
|
||||||
|
<option value="month">30 jours</option>
|
||||||
|
</select>
|
||||||
|
<label class="terminal-history-scope">
|
||||||
|
<input type="checkbox" id="terminalHistoryAllHosts" onchange="toggleHistoryScope()">
|
||||||
|
<span>Tous hôtes</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-history-list" id="terminalHistoryList">
|
||||||
|
<div class="terminal-history-loading">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i> Chargement...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-history-footer">
|
||||||
|
<span class="terminal-history-hint">
|
||||||
|
<kbd>↑</kbd><kbd>↓</kbd> naviguer · <kbd>Enter</kbd> insérer · <kbd>Esc</kbd> fermer
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ script_block | safe }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
48
app/templates/terminal/error.html
Normal file
48
app/templates/terminal/error.html
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
.error h1 { color: #ef4444; }
|
||||||
|
.error p { color: #e5e7eb; margin-bottom: 0.75rem; }
|
||||||
|
.error a { color: #60a5fa; }
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
background: #7c3aed;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="error">
|
||||||
|
<h1>{{ heading }}</h1>
|
||||||
|
{% for msg in messages %}
|
||||||
|
<p>{{ msg }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% if show_back_link %}
|
||||||
|
<a class="btn" href="/">Retour au Dashboard</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
899
documentation/AUDIT_STRATEGIQUE_COMPLET.md
Normal file
899
documentation/AUDIT_STRATEGIQUE_COMPLET.md
Normal file
@ -0,0 +1,899 @@
|
|||||||
|
# 🔍 Rapport d'Audit Technique Stratégique — Homelab Automation Dashboard v2.0
|
||||||
|
|
||||||
|
> **Date de l'audit :** 20 février 2026
|
||||||
|
> **Auditeur :** Architecte Logiciel Full-Stack Senior / Spécialiste DevOps / Expert UI/UX
|
||||||
|
> **Version analysée :** 2.0.0
|
||||||
|
> **Stack :** FastAPI · SQLAlchemy Async · SQLite (aiosqlite) · APScheduler · Ansible · WebSocket · HTML/JS/Tailwind/Anime.js
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table des Matières
|
||||||
|
|
||||||
|
1. [Synthèse Exécutive](#1-synthèse-exécutive)
|
||||||
|
2. [Audit d'Architecture et de Sécurité 🛡️](#2-audit-darchitecture-et-de-sécurité-)
|
||||||
|
3. [Corrections et Optimisations Code/Performances ⚙️](#3-corrections-et-optimisations-codeperformances-)
|
||||||
|
4. [Améliorations UI/UX 🎨](#4-améliorations-uiux-)
|
||||||
|
5. [Idées d'Évolution "Next Level" 🚀](#5-idées-dévolution-next-level-)
|
||||||
|
6. [Feuille de Route Actionnable 🗺️](#6-feuille-de-route-actionnable-)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Synthèse Exécutive
|
||||||
|
|
||||||
|
### Vue d'ensemble du Projet
|
||||||
|
|
||||||
|
Le Homelab Automation Dashboard est une application **self-hosted** impressionnante qui centralise la gestion d'infrastructure via Ansible, avec planification de tâches, monitoring Docker via SSH, un terminal SSH intégré via ttyd, et des notifications push via ntfy. L'application a atteint un niveau de maturité fonctionnel **solide** avec une architecture bien structurée (Factory pattern, Repository pattern, séparation services/routes/schemas/models).
|
||||||
|
|
||||||
|
### Points Forts ✅
|
||||||
|
|
||||||
|
| Domaine | Appréciation |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Architecture modulaire** | Excellente séparation : `routes/`, `services/`, `models/`, `schemas/`, `crud/`, `core/` |
|
||||||
|
| **Migrations Alembic** | 19 migrations bien structurées avec convention de nommage |
|
||||||
|
| **Exécution Ansible async** | `asyncio.create_subprocess_exec` — ne bloque **pas** l'event loop ✅ |
|
||||||
|
| **Service de notification** | Design robuste, never-throw, async, avec templates et filtrage par niveau |
|
||||||
|
| **Gestion des sessions terminal** | Architecture GC + heartbeat + session reuse bien pensée |
|
||||||
|
| **Docker monitoring** | Collection SSH + semaphore concurrency limiter + upsert/stale cleanup |
|
||||||
|
| **Startup checks** | Service de vérification des prérequis complet et bien reporté |
|
||||||
|
| **Exceptions typées** | Hiérarchie d'exceptions métier bien conçue (HomelabException) |
|
||||||
|
|
||||||
|
### Points d'Attention ⚠️
|
||||||
|
|
||||||
|
| Domaine | Sévérité | Résumé |
|
||||||
|
|---------|----------|--------|
|
||||||
|
| **Secrets en `.env` committé** | 🔴 Critique | `.env` contient `API_KEY`, `JWT_SECRET_KEY` en clair dans le repo |
|
||||||
|
| **`app_optimized.py` monolithique** | 🟠 Haute | 6585 lignes — fichier "God Object" qui duplique toute l'architecture |
|
||||||
|
| **WebSocket sans authentification** | 🟠 Haute | `/ws` n'exige aucun token/clé API |
|
||||||
|
| **Bootstrap : mot de passe root en transit** | 🟠 Haute | `root_password` transmis en clair dans la requête HTTP |
|
||||||
|
| **`threading.Lock` dans WebSocket** | 🟡 Moyenne | Devrait être `asyncio.Lock` pour la cohérence async |
|
||||||
|
| **CORS `allow_origins=["*"]`** | 🟡 Moyenne | Présent dans `app_optimized.py` et en production potentielle |
|
||||||
|
| **Coverage à 45%** | 🟡 Moyenne | Seuil minimal, certaines zones critiques non couvertes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Audit d'Architecture et de Sécurité 🛡️
|
||||||
|
|
||||||
|
### 2.1 Authentification & Gestion des Secrets
|
||||||
|
|
||||||
|
#### JWT — Implémentation Solide avec Réserves
|
||||||
|
|
||||||
|
L'implémentation JWT utilise `python-jose` avec `bcrypt` pour le hashing des mots de passe. C'est un bon choix.
|
||||||
|
|
||||||
|
**✅ Points positifs :**
|
||||||
|
- Hashing bcrypt avec sel aléatoire (`bcrypt.gensalt()`)
|
||||||
|
- Token avec `exp` et `iat` claims
|
||||||
|
- Double mode d'authentification (API Key + JWT Bearer)
|
||||||
|
- Setup initial protégé (uniquement si 0 utilisateurs)
|
||||||
|
- Changement de mot de passe avec vérification de l'ancien
|
||||||
|
|
||||||
|
**🔴 Problèmes critiques :**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# auth_service.py, ligne 20
|
||||||
|
SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "homelab-secret-key-change-in-production")
|
||||||
|
```
|
||||||
|
|
||||||
|
| Problème | Impact | Recommandation |
|
||||||
|
|----------|--------|----------------|
|
||||||
|
| **Clé secrète par défaut faible** | Un attaquant connaissant le code source peut forger des JWT valides | Générer une clé de 256 bits minimum au premier démarrage, stocker dans un fichier protégé |
|
||||||
|
| **Pas de rotation des clés** | Compromission permanente si la clé est exposée | Implémenter JWK (JSON Web Key) avec rotation périodique |
|
||||||
|
| **Pas de refresh token** | L'utilisateur doit se reconnecter après expiration | Ajouter un système refresh/access token |
|
||||||
|
| **Pas de révocation de token** | Impossible de déconnecter un utilisateur compromis | Maintenir une blacklist en cache (Redis ou in-memory) |
|
||||||
|
|
||||||
|
#### `.env` Commité dans le Repository
|
||||||
|
|
||||||
|
```
|
||||||
|
# .env (commité dans git !)
|
||||||
|
API_KEY=dev-key-1234567890
|
||||||
|
JWT_SECRET_KEY=dev-key-67890
|
||||||
|
```
|
||||||
|
|
||||||
|
**🔴 CRITIQUE :** Le fichier `.env` est **commité** et contient des secrets en clair. Même si ce sont des valeurs de développement, cela crée un risque car :
|
||||||
|
1. Les développeurs pourraient déployer avec ces valeurs
|
||||||
|
2. L'historique Git conserve les secrets même après suppression
|
||||||
|
|
||||||
|
**Recommandations :**
|
||||||
|
1. Ajouter `.env` au `.gitignore` immédiatement
|
||||||
|
2. Conserver uniquement un `.env.example` avec des valeurs placeholder
|
||||||
|
3. Utiliser `python-dotenv` avec validation obligatoire des secrets au démarrage
|
||||||
|
4. Pour la production, envisager HashiCorp Vault ou `docker secrets`
|
||||||
|
|
||||||
|
#### Gestion des Clés SSH
|
||||||
|
|
||||||
|
**✅ Points positifs :**
|
||||||
|
- Recherche intelligente multi-emplacement (`find_ssh_private_key`)
|
||||||
|
- Vérification des permissions sur Linux/Mac
|
||||||
|
- Support de multiples types de clés (RSA, Ed25519, ECDSA)
|
||||||
|
|
||||||
|
**⚠️ Points d'attention :**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ssh_utils.py, ligne 103 & terminal_service.py, ligne 363
|
||||||
|
"-o", "StrictHostKeyChecking=no",
|
||||||
|
"-o", "UserKnownHostsFile=/dev/null",
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela désactive la vérification TOFU (Trust On First Use), ce qui est acceptable pour un homelab mais devrait être configurable.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# docker_service.py, ligne 88
|
||||||
|
known_hosts=None, # Accept any host key (homelab environment)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommandation :** Ajouter un paramètre `STRICT_HOST_KEY_CHECKING` configurable avec une valeur par défaut `no` pour le homelab, `yes` pour la production.
|
||||||
|
|
||||||
|
#### Bootstrap — Mot de Passe Root en Transit
|
||||||
|
|
||||||
|
```python
|
||||||
|
# routes/bootstrap.py, ligne 98
|
||||||
|
result = bootstrap_host(
|
||||||
|
host=request.host,
|
||||||
|
root_password=request.root_password, # ⚠️ En clair dans la requête
|
||||||
|
automation_user=request.automation_user
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le mot de passe root est transmis dans le corps de la requête HTTP. En l'absence de HTTPS, il circule en clair.
|
||||||
|
|
||||||
|
**Recommandations :**
|
||||||
|
1. **Exiger HTTPS** pour les endpoints sensibles (bootstrap, auth)
|
||||||
|
2. Ajouter un avertissement dans les logs si la requête arrive en HTTP
|
||||||
|
3. Envisager un chiffrement côté client avec une clé éphémère (Diffie-Hellman)
|
||||||
|
4. Limiter le rate-limiting sur `/api/bootstrap` (anti brute-force)
|
||||||
|
|
||||||
|
### 2.2 Robustesse de l'API FastAPI
|
||||||
|
|
||||||
|
#### Architecture Dual — Le Problème `app_optimized.py`
|
||||||
|
|
||||||
|
L'application maintient **deux architectures parallèles** :
|
||||||
|
|
||||||
|
| Fichier | Lignes | Rôle |
|
||||||
|
|---------|--------|------|
|
||||||
|
| `app/factory.py` + routes modulaires | ~10k+ | Architecture propre, modulaire |
|
||||||
|
| `app/app_optimized.py` | **6585** | Monolithe qui duplique tout |
|
||||||
|
|
||||||
|
**🟠 Problème majeur :** `app_optimized.py` est un "God File" de 6585 lignes contenant :
|
||||||
|
- Modèles Pydantic (dupliqués de `schemas/`)
|
||||||
|
- Services (dupliqués de `services/`)
|
||||||
|
- Routes (dupliquées de `routes/`)
|
||||||
|
- Gestion de base de données en mémoire **et** SQLite synchrone
|
||||||
|
- Un scheduler complet
|
||||||
|
|
||||||
|
**Recommandation :** Supprimer `app_optimized.py` et ne conserver que l'architecture modulaire. Ce fichier semble être l'ancienne version monolithique conservée "au cas où" mais il crée de la confusion et un risque de régression.
|
||||||
|
|
||||||
|
#### Gestion de la Base de Données SQLite Async
|
||||||
|
|
||||||
|
**✅ Excellente configuration :**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# models/database.py
|
||||||
|
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True, future=True)
|
||||||
|
|
||||||
|
# Pragmas SQLite bien configurés
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.execute("PRAGMA journal_mode=WAL") # Avec fallback sur DELETE
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Session management correct :**
|
||||||
|
```python
|
||||||
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Points d'attention :**
|
||||||
|
|
||||||
|
1. **Pas de connection pooling avancé** : SQLite single-writer peut devenir un bottleneck sous charge élevée. Envisager `pool_size` et `max_overflow` quand/si migration vers PostgreSQL.
|
||||||
|
|
||||||
|
2. **`expire_on_commit=False`** : C'est le bon choix pour l'async, mais peut causer des données stale si les sessions sont longues. Actuellement OK car les sessions sont scoped aux requêtes.
|
||||||
|
|
||||||
|
3. **Migration Alembic dans `init_db()`** : L'exécution de `alembic upgrade head` à chaque démarrage est robuste mais peut être lente si beaucoup de migrations. Ajouter un check "already at head" avant d'exécuter.
|
||||||
|
|
||||||
|
### 2.3 Communication WebSocket
|
||||||
|
|
||||||
|
#### WebSocket Manager — Simple mais Fonctionnel
|
||||||
|
|
||||||
|
```python
|
||||||
|
# websocket_service.py
|
||||||
|
class WebSocketManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.active_connections: List[WebSocket] = []
|
||||||
|
self.lock = Lock() # ⚠️ threading.Lock au lieu de asyncio.Lock
|
||||||
|
```
|
||||||
|
|
||||||
|
**🟡 Problème : `threading.Lock` en contexte async**
|
||||||
|
|
||||||
|
L'utilisation de `threading.Lock` dans la méthode `broadcast` (qui est `async`) peut causer un deadlock si jamais le lock est contenu quand l'event loop essaie d'envoyer un message WebSocket qui nécessite un `await`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Actuel (problématique)
|
||||||
|
async def broadcast(self, message: dict):
|
||||||
|
with self.lock: # ⚠️ Bloque le thread de l'event loop
|
||||||
|
for connection in self.active_connections:
|
||||||
|
await connection.send_json(message) # ← await sous un Lock synchrone
|
||||||
|
```
|
||||||
|
|
||||||
|
**La correction devrait être :**
|
||||||
|
```python
|
||||||
|
async def broadcast(self, message: dict):
|
||||||
|
async with self._lock: # asyncio.Lock
|
||||||
|
disconnected = []
|
||||||
|
for connection in self.active_connections:
|
||||||
|
try:
|
||||||
|
await connection.send_json(message)
|
||||||
|
except Exception:
|
||||||
|
disconnected.append(connection)
|
||||||
|
for conn in disconnected:
|
||||||
|
self.active_connections.remove(conn)
|
||||||
|
```
|
||||||
|
|
||||||
|
**🔴 WebSocket sans authentification :**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# routes/websocket.py, ligne 22-32
|
||||||
|
@router.websocket("/ws")
|
||||||
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
|
await ws_manager.connect(websocket) # ⚠️ Aucune vérification d'identité
|
||||||
|
```
|
||||||
|
|
||||||
|
N'importe qui peut se connecter au WebSocket et recevoir toutes les notifications système (tâches, bootstrap, statuts). C'est un **vecteur de fuite d'information**.
|
||||||
|
|
||||||
|
**Recommandation :** Vérifier le JWT via query parameter lors du handshake WebSocket :
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.websocket("/ws")
|
||||||
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
|
token = websocket.query_params.get("token")
|
||||||
|
if not token or not decode_token(token):
|
||||||
|
await websocket.close(code=4001, reason="Authentication required")
|
||||||
|
return
|
||||||
|
await ws_manager.connect(websocket)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Terminal WebSocket Proxy
|
||||||
|
|
||||||
|
Le proxy terminal (`/terminal/ws/{session_id}`) a une **bonne implémentation de sécurité** :
|
||||||
|
- ✅ Token vérifié via hash SHA-256
|
||||||
|
- ✅ `secrets.compare_digest` pour la comparaison (timing-safe)
|
||||||
|
- ✅ Session validée en base de données
|
||||||
|
|
||||||
|
**⚠️ Problème mineur dans le proxy :**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# websocket.py, ligne 104
|
||||||
|
data = await reader.readexactly(1024) # ⚠️ Attend exactement 1024 octets
|
||||||
|
```
|
||||||
|
|
||||||
|
`readexactly` attend exactement N bytes ou lève `IncompleteReadError`. Cela peut causer des latences si le terminal envoie moins de 1024 bytes. Utiliser `reader.read(4096)` à la place :
|
||||||
|
|
||||||
|
```python
|
||||||
|
data = await reader.read(4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Planificateur de Tâches (APScheduler)
|
||||||
|
|
||||||
|
**✅ Configuration solide :**
|
||||||
|
- `coalesce=True` : évite les exécutions multiples si le scheduler rattrape son retard
|
||||||
|
- `max_instances=1` : empêche les exécutions parallèles du même job
|
||||||
|
- `misfire_grace_time` configurable
|
||||||
|
- Persistance en base de données avec rechargement au démarrage
|
||||||
|
|
||||||
|
**⚠️ Risque de "Schedule Orphan" :**
|
||||||
|
|
||||||
|
Si l'application crash pendant l'exécution d'un schedule, le `ScheduleRun` restera en statut `running` indéfiniment. Ajouter une vérification au démarrage pour marquer les runs "orphelins" comme `failed`.
|
||||||
|
|
||||||
|
**⚠️ Jobs Docker hardcodés :**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# factory.py, lignes 182-198
|
||||||
|
scheduler_service.scheduler.add_job(
|
||||||
|
docker_service.collect_all_hosts,
|
||||||
|
trigger="interval",
|
||||||
|
seconds=60, # Toutes les minutes
|
||||||
|
id="docker_collect",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Les intervalles sont hardcodés. Rendre configurable via variables d'environnement (`DOCKER_COLLECT_INTERVAL`, `DOCKER_ALERTS_INTERVAL`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Corrections et Optimisations Code/Performances ⚙️
|
||||||
|
|
||||||
|
### 3.1 Anti-Patterns Identifiés
|
||||||
|
|
||||||
|
#### AP-1 : Fichier Monolithique `app_optimized.py`
|
||||||
|
|
||||||
|
| Métrique | Valeur |
|
||||||
|
|----------|--------|
|
||||||
|
| Lignes | 6585 |
|
||||||
|
| Classes | ~30+ |
|
||||||
|
| Fonctions | ~200+ |
|
||||||
|
| Impact | Maintenance, lisibilité, testabilité |
|
||||||
|
|
||||||
|
**Action :** Supprimer ce fichier et s'appuyer uniquement sur l'architecture modulaire existante (qui est déjà complète et fonctionnelle).
|
||||||
|
|
||||||
|
#### AP-2 : Import Circulaire Protégé par Try/Except
|
||||||
|
|
||||||
|
```python
|
||||||
|
# notification_service.py, lignes 32-47
|
||||||
|
try:
|
||||||
|
from schemas.notification import (...)
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
from app.schemas.notification import (...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce pattern indique une incertitude sur le `sys.path`. C'est un symptôme de `app_optimized.py` qui fonctionne avec un chemin différent.
|
||||||
|
|
||||||
|
**Action :** Après suppression de `app_optimized.py`, utiliser uniquement les imports absolus `from app.schemas...`.
|
||||||
|
|
||||||
|
#### AP-3 : Double Système de Persistance (HybridDB)
|
||||||
|
|
||||||
|
Le service `HybridDB` (`services/hybrid_db.py`) maintient des données **en mémoire** (listes Python) en parallèle avec SQLite. Cela crée une incohérence potentielle.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# routes/bootstrap.py, ligne 140
|
||||||
|
db.logs.insert(0, log_entry) # ← Mémoire
|
||||||
|
```
|
||||||
|
|
||||||
|
vs.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Ailleurs dans le code
|
||||||
|
repo = LogRepository(session)
|
||||||
|
await repo.create(...) # ← SQLite
|
||||||
|
```
|
||||||
|
|
||||||
|
**Action :** Migrer entièrement vers le pattern Repository + SQLite. Le `HybridDB` est un vestige de l'ancienne architecture.
|
||||||
|
|
||||||
|
#### AP-4 : Fonctions Synchrones Bloquantes
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ssh_utils.py, lignes 128-133
|
||||||
|
result = subprocess.run( # ⚠️ BLOQUE l'event loop
|
||||||
|
ssh_cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout + 10
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
La fonction `bootstrap_host()` utilise `subprocess.run` synchrone et est appelée depuis une route `async` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
# routes/bootstrap.py, ligne 98
|
||||||
|
result = bootstrap_host(...) # ← Appel synchrone dans une route async
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela **bloque l'event loop** pendant toute la durée du bootstrap (jusqu'à 120 secondes).
|
||||||
|
|
||||||
|
**Correction :**
|
||||||
|
```python
|
||||||
|
# Utiliser asyncio.to_thread pour les opérations bloquantes
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
bootstrap_host,
|
||||||
|
host=request.host,
|
||||||
|
root_password=request.root_password,
|
||||||
|
automation_user=request.automation_user
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou mieux, réécrire `bootstrap_host` avec `asyncio.create_subprocess_exec` comme `ansible_service.execute_playbook` le fait déjà correctement.
|
||||||
|
|
||||||
|
#### AP-5 : `asyncio.create_task` Sans Référence
|
||||||
|
|
||||||
|
```python
|
||||||
|
# routes/bootstrap.py, ligne 152
|
||||||
|
asyncio.create_task(notification_service.notify_bootstrap_success(host_name))
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tâches créées avec `asyncio.create_task` sans référence peuvent être garbage-collectées avant leur fin si rien ne les retient. De plus, les exceptions non attrapées dans ces tâches seront silencieusement perdues.
|
||||||
|
|
||||||
|
**Correction :**
|
||||||
|
```python
|
||||||
|
# Stocker une référence et ajouter un callback d'erreur
|
||||||
|
background_tasks = set()
|
||||||
|
|
||||||
|
task = asyncio.create_task(notification_service.notify_bootstrap_success(host_name))
|
||||||
|
background_tasks.add(task)
|
||||||
|
task.add_done_callback(background_tasks.discard)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou utiliser `BackgroundTasks` de FastAPI :
|
||||||
|
```python
|
||||||
|
from fastapi import BackgroundTasks
|
||||||
|
|
||||||
|
@router.post("", response_model=CommandResult)
|
||||||
|
async def bootstrap_ansible_host(
|
||||||
|
request: BootstrapRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
api_key_valid: bool = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
# ...
|
||||||
|
background_tasks.add_task(notification_service.notify_bootstrap_success, host_name)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Goulots d'Étranglement Identifiés
|
||||||
|
|
||||||
|
#### GE-1 : Scan Système de Fichiers pour les Logs de Tâches
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app_optimized.py, lignes 519-565
|
||||||
|
def _build_index(self, force: bool = False):
|
||||||
|
for year_dir in self.base_dir.iterdir():
|
||||||
|
for month_dir in year_dir.iterdir():
|
||||||
|
for day_dir in month_dir.iterdir():
|
||||||
|
for md_file in day_dir.glob("*.md"):
|
||||||
|
# Parse chaque fichier
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce scan récursif du système de fichiers à chaque appel (avec un cache de 60 secondes) sera lent si des centaines/milliers de fichiers de logs s'accumulent.
|
||||||
|
|
||||||
|
**Optimisation :** Le service `TaskLogService` dans `services/task_log_service.py` devrait indexer les logs en base de données plutôt que scanner le filesystem. Créer une table `task_logs_index` :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE task_logs_index (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
task_name TEXT,
|
||||||
|
target TEXT,
|
||||||
|
status TEXT,
|
||||||
|
date TEXT,
|
||||||
|
source_type TEXT,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
metadata_json TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GE-2 : Docker Collection Séquentielle par Hôte
|
||||||
|
|
||||||
|
La collecte Docker utilise un semaphore de 5, ce qui est bien, mais chaque hôte fait 4 commandes SSH séquentielles (`docker version`, `docker ps`, `docker images`, `docker volume ls`).
|
||||||
|
|
||||||
|
**Optimisation :** Combiner les commandes en une seule session SSH :
|
||||||
|
|
||||||
|
```python
|
||||||
|
combined_cmd = """
|
||||||
|
echo '---VERSION---'
|
||||||
|
docker version --format '{{.Server.Version}}'
|
||||||
|
echo '---CONTAINERS---'
|
||||||
|
docker ps -a --format '{{json .}}' --no-trunc
|
||||||
|
echo '---IMAGES---'
|
||||||
|
docker images --format '{{json .}}'
|
||||||
|
echo '---VOLUMES---'
|
||||||
|
docker volume ls --format '{{json .}}'
|
||||||
|
"""
|
||||||
|
stdout, stderr, code = await self._ssh_exec(conn, combined_cmd, timeout=30)
|
||||||
|
# Parser les sections
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela réduit le nombre de round-trips SSH de 4 à 1 par hôte.
|
||||||
|
|
||||||
|
#### GE-3 : `index.html` Monolithique (247 KB)
|
||||||
|
|
||||||
|
Le fichier `index.html` fait **247 KB** et `main.js` fait **617 KB**. Ces fichiers ne sont ni minifiés ni compressés.
|
||||||
|
|
||||||
|
**Optimisations :**
|
||||||
|
1. Activer la compression gzip/brotli via middleware FastAPI
|
||||||
|
2. Ajouter des headers de cache (`Cache-Control`, `ETag`)
|
||||||
|
3. Diviser `main.js` en modules ES (chargement paresseux des sections)
|
||||||
|
|
||||||
|
### 3.3 Améliorations des Pratiques de Code
|
||||||
|
|
||||||
|
#### Python — Bonnes Pratiques
|
||||||
|
|
||||||
|
| # | Recommandation | Fichier(s) | Priorité |
|
||||||
|
|---|---------------|------------|----------|
|
||||||
|
| 1 | Utiliser `Annotated` de typing pour les dépendances FastAPI | Toutes les routes | Basse |
|
||||||
|
| 2 | Remplacer `@app.on_event("startup")` par les `lifespan` events (FastAPI 0.93+) | `factory.py` | Moyenne |
|
||||||
|
| 3 | Ajouter des type hints manquants dans `HybridDB` | `services/hybrid_db.py` | Basse |
|
||||||
|
| 4 | Utiliser `logging` au lieu de `print()` dans `factory.py` | `factory.py` (15+ prints) | Moyenne |
|
||||||
|
| 5 | Remplacer les f-strings dans les loggers par `%s` formatage | Partout | Basse |
|
||||||
|
| 6 | Ajouter `__slots__` aux dataclasses de configuration | `core/config.py` | Basse |
|
||||||
|
| 7 | Utiliser `enum.StrEnum` pour les statuts (`"running"`, `"failed"`, etc.) | `schemas/`, `models/` | Moyenne |
|
||||||
|
|
||||||
|
#### Python — Sécurité du Code
|
||||||
|
|
||||||
|
```python
|
||||||
|
# routes/bootstrap.py, ligne 92-94 — Import à l'intérieur de la fonction
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
logger = logging.getLogger("bootstrap_endpoint")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommandation :** Déplacer les imports et la création du logger au niveau du module (en haut du fichier).
|
||||||
|
|
||||||
|
#### JavaScript — Bonnes Pratiques
|
||||||
|
|
||||||
|
| # | Recommandation | Fichier | Priorité |
|
||||||
|
|---|---------------|---------|----------|
|
||||||
|
| 1 | Migrer de `var` vers `const/let` | `main.js` | Moyenne |
|
||||||
|
| 2 | Utiliser ES Modules (`import/export`) au lieu du scope global | `main.js` et `*.js` | Haute |
|
||||||
|
| 3 | Ajouter un linter (ESLint) avec config stricte | Projet | Moyenne |
|
||||||
|
| 4 | Implémenter le debouncing sur les appels API fréquents | `main.js` | Moyenne |
|
||||||
|
| 5 | Ajouter une couche d'abstraction API (fetch wrapper) | `main.js` | Haute |
|
||||||
|
|
||||||
|
### 3.4 Correctifs Prioritaires
|
||||||
|
|
||||||
|
#### FIX-1 : `require_admin` ne vérifie pas le bon champ
|
||||||
|
|
||||||
|
```python
|
||||||
|
# core/dependencies.py, lignes 161-162
|
||||||
|
payload = user.get("payload", {}) # ⚠️ Le champ "payload" n'existe pas !
|
||||||
|
role = payload.get("role", "viewer")
|
||||||
|
```
|
||||||
|
|
||||||
|
Le dictionnaire user retourné par `get_current_user_optional` contient `role` directement, pas dans un sous-dictionnaire `payload`. La vérification admin ne fonctionnera **jamais** pour les utilisateurs JWT.
|
||||||
|
|
||||||
|
**Correction :**
|
||||||
|
```python
|
||||||
|
async def require_admin(user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
if user.get("type") == "api_key":
|
||||||
|
return user
|
||||||
|
role = user.get("role", "viewer") # Directement dans le dict
|
||||||
|
if role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Droits administrateur requis")
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
#### FIX-2 : Fuite mémoire potentielle dans WebSocket
|
||||||
|
|
||||||
|
Le `WebSocketManager` ne nettoie les connexions mortes que lors d'un `broadcast`. Si aucun broadcast n'est envoyé pendant longtemps, les connexions mortes s'accumulent.
|
||||||
|
|
||||||
|
**Correction :** Ajouter un heartbeat périodique :
|
||||||
|
```python
|
||||||
|
async def _heartbeat_loop(self):
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
await self.broadcast({"type": "ping"})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### FIX-3 : Terminal proxy — `readexactly` vs `read`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# routes/websocket.py, ligne 104
|
||||||
|
data = await reader.readexactly(1024) # Bloque jusqu'à avoir exactement 1024 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correction :** `data = await reader.read(4096)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Améliorations UI/UX 🎨
|
||||||
|
|
||||||
|
### 4.1 Ergonomie du Tableau de Bord
|
||||||
|
|
||||||
|
#### Dashboard — Structure Recommandée
|
||||||
|
|
||||||
|
Le dashboard actuel charge un fichier HTML monolithique de 247 KB. Voici les améliorations ergonomiques recommandées :
|
||||||
|
|
||||||
|
| Zone | Amélioration | Impact |
|
||||||
|
|------|-------------|--------|
|
||||||
|
| **Navigation** | Sidebar rétractable avec icônes et badges de notification | Haute |
|
||||||
|
| **Page d'accueil** | Ajouter des "sparklines" (micro-graphiques) pour les métriques | Moyenne |
|
||||||
|
| **Filtres** | Filtrage en temps réel avec chips/tags visuels | Haute |
|
||||||
|
| **Breadcrumbs** | Ajouter une navigation hiérarchique | Moyenne |
|
||||||
|
| **Raccourcis clavier** | `Ctrl+K` pour recherche rapide, `Ctrl+T` pour nouveau terminal | Moyenne |
|
||||||
|
| **Dark/Light mode** | Toggle avec persistance en `localStorage` | Moyenne |
|
||||||
|
|
||||||
|
#### Gestion des Erreurs — Feedback Visuel
|
||||||
|
|
||||||
|
Actuellement, les erreurs sont probablement affichées dans des `alert()` ou des toasts basiques. Voici un système plus professionnel :
|
||||||
|
|
||||||
|
**Système de Toast Notifications à 4 Niveaux :**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ ✅ SUCCESS │
|
||||||
|
│ "Playbook vm-upgrade.yml exécuté" │
|
||||||
|
│ 3 hôtes mis à jour en 45s │
|
||||||
|
│ ░░░░░░░░░░░░░░░░░░░░░░ auto-dismiss 5s │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ ❌ ERROR │
|
||||||
|
│ "Bootstrap échoué pour srv-backup" │
|
||||||
|
│ Connection timeout après 30s │
|
||||||
|
│ [Voir les logs] [Réessayer] │
|
||||||
|
│ ► Reste affiché jusqu'au dismiss │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implémentation recommandée :**
|
||||||
|
- Position : coin supérieur droit, empilées
|
||||||
|
- Auto-dismiss : 5s pour success/info, sticky pour errors
|
||||||
|
- Actions contextuelles intégrées ("Voir les logs", "Réessayer")
|
||||||
|
- Animation entrée/sortie via Anime.js
|
||||||
|
|
||||||
|
### 4.2 Intégration Anime.js — Suggestions
|
||||||
|
|
||||||
|
| Animation | Déclencheur | Effet |
|
||||||
|
|-----------|------------|-------|
|
||||||
|
| **Task Progress** | Pendant l'exécution | Barre de progression animée avec shimmer |
|
||||||
|
| **Host Status Change** | WebSocket event | Pulse animation sur le badge de statut |
|
||||||
|
| **Docker Container Start/Stop** | Action utilisateur | Icon spin + color morph |
|
||||||
|
| **Page Transition** | Navigation SPA | Fade-in avec translate-Y léger (16px) |
|
||||||
|
| **Card Hover** | Mouse enter | Scale(1.02) + shadow elevation subtile |
|
||||||
|
| **Data Loading** | Fetch en cours | Skeleton screens avec wave animation |
|
||||||
|
| **Error Shake** | Validation échouée | Shake horizontal (6px, 3 cycles, 400ms) |
|
||||||
|
|
||||||
|
### 4.3 Composants UI Recommandés
|
||||||
|
|
||||||
|
#### Terminal SSH — Améliorations
|
||||||
|
|
||||||
|
Le terminal SSH intégré est une fonctionnalité killer. Améliorations suggérées :
|
||||||
|
|
||||||
|
1. **Split panes** : Permettre de diviser le terminal en panneaux horizontaux/verticaux
|
||||||
|
2. **Quick commands** : Palette de commandes pré-définies (sidebar ou dropdown)
|
||||||
|
3. **Session history** : Recall des commandes précédentes avec recherche fuzzy
|
||||||
|
4. **Tab management** : Onglets pour multiples sessions, avec indicateur d'activité
|
||||||
|
5. **Copy-paste amélioré** : Double-clic pour sélection de mot, click-and-drag pour sélection
|
||||||
|
|
||||||
|
#### Playbook Editor — Améliorations
|
||||||
|
|
||||||
|
1. **Syntax highlighting YAML** : Déjà intégré via CodeMirror ✅
|
||||||
|
2. **Live linting** : Intégrer ansible-lint en temps réel (via WebSocket)
|
||||||
|
3. **Variables autocomplete** : Complétion automatique des variables de groupe
|
||||||
|
4. **Diff view** : Voir les changements avant sauvegarde
|
||||||
|
5. **Template snippets** : Bibliothèque de snippets de tâches courantes
|
||||||
|
|
||||||
|
### 4.4 Accessibilité (a11y)
|
||||||
|
|
||||||
|
| Aspect | État Actuel | Recommandation |
|
||||||
|
|--------|------------|----------------|
|
||||||
|
| **Contraste** | À vérifier | WCAG AA minimum (ratio 4.5:1) |
|
||||||
|
| **Navigation clavier** | Probablement partielle | `tabindex` sur tous les éléments interactifs |
|
||||||
|
| **Screen reader** | Non implémenté | Ajouter `aria-label`, `aria-live` pour les mises à jour dynamiques |
|
||||||
|
| **Focus visible** | Par défaut navigateur | Personnaliser le style de focus (`outline`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Idées d'Évolution "Next Level" 🚀
|
||||||
|
|
||||||
|
### 5.1 Gestion des Secrets (Vault)
|
||||||
|
|
||||||
|
**Objectif :** Centraliser et sécuriser tous les secrets (mots de passe, clés API, tokens).
|
||||||
|
|
||||||
|
**Architecture proposée :**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Homelab Secrets Manager │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────┐ ┌──────────┐ ┌────────────┐ │
|
||||||
|
│ │ Ansible │ │ Docker │ │ Services │ │
|
||||||
|
│ │ Vault │ │ Secrets │ │ Credentials│ │
|
||||||
|
│ └─────────┘ └──────────┘ └────────────┘ │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Backend: AES-256-GCM encryption at rest │
|
||||||
|
│ Master Key: derived via PBKDF2 from │
|
||||||
|
│ user password + hardware fingerprint │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implémentation par phases :**
|
||||||
|
1. **Phase 1 :** Chiffrement des secrets en base (clés SSH, tokens ntfy)
|
||||||
|
2. **Phase 2 :** Interface UI pour gérer les secrets (CRUD + audit log)
|
||||||
|
3. **Phase 3 :** Intégration avec `ansible-vault` pour les playbooks
|
||||||
|
|
||||||
|
### 5.2 Intégration Proxmox
|
||||||
|
|
||||||
|
**Objectif :** Gérer les VMs et containers LXC directement depuis le dashboard.
|
||||||
|
|
||||||
|
**Fonctionnalités proposées :**
|
||||||
|
|
||||||
|
| Fonctionnalité | Complexité | Valeur |
|
||||||
|
|---------------|-----------|--------|
|
||||||
|
| Lister VMs/CTs avec statut | Faible | Haute |
|
||||||
|
| Start/Stop/Restart VM | Moyenne | Haute |
|
||||||
|
| Créer VM from template | Haute | Moyenne |
|
||||||
|
| Monitoring CPU/RAM/IO | Moyenne | Haute |
|
||||||
|
| Snapshots management | Moyenne | Moyenne |
|
||||||
|
| Console VNC/SPICE intégrée | Haute | Haute |
|
||||||
|
|
||||||
|
**Approche technique :**
|
||||||
|
- Utiliser l'API REST de Proxmox VE (`/api2/json/`)
|
||||||
|
- Authentification via token PVE (pas de mot de passe stocké)
|
||||||
|
- WebSocket pour le streaming des métriques en temps réel
|
||||||
|
|
||||||
|
### 5.3 Monitoring Avancé
|
||||||
|
|
||||||
|
#### Métriques Système Enrichies
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ CPU History (24h) │
|
||||||
|
│ ██████████░░░░░░░░░ 52% avg │
|
||||||
|
│ ▁▂▃▅▆▇█▇▅▃▂▁▁▂▃▅▇█▇▅▃▂▁ │
|
||||||
|
├──────────────────────────────────────────┤
|
||||||
|
│ Memory ████████████████░░ 78% (12/16GB)│
|
||||||
|
│ Swap █░░░░░░░░░░░░░░░░ 3% (0.5/16GB)│
|
||||||
|
│ Disk / ██████████░░░░░░░ 62% (120/200G)│
|
||||||
|
│ Network ↑ 12 Mbit/s ↓ 45 Mbit/s │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stack recommandé :**
|
||||||
|
- **Collection :** Via Ansible ad-hoc (déjà capable) ou node_exporter
|
||||||
|
- **Stockage :** SQLite pour 7 jours, puis agrégation/cleanup automatique
|
||||||
|
- **Visualisation :** Graphiques temps réel en JavaScript (Chart.js ou D3.js)
|
||||||
|
- **Alerting :** Intégration avec ntfy (déjà en place)
|
||||||
|
|
||||||
|
#### Alerting Intelligent
|
||||||
|
|
||||||
|
Passer d'alertes simples (seuil dépassé) à un système plus sophistiqué :
|
||||||
|
|
||||||
|
1. **Rate of change :** Alerter si le CPU augmente de >30% en 5 minutes
|
||||||
|
2. **Anomaly detection :** Baseline automatique + détection de déviation
|
||||||
|
3. **Correlation :** "Disk full → Service crash" → alerte unifiée
|
||||||
|
4. **Escalation :** ntfy (info) → email (warning) → SMS/appel (critique)
|
||||||
|
|
||||||
|
### 5.4 Intégration Docker Compose Avancée
|
||||||
|
|
||||||
|
**Fonctionnalités proposées :**
|
||||||
|
|
||||||
|
1. **Visualisation des stacks** : Graphe de dépendances entre containers
|
||||||
|
2. **Logs en streaming** : `docker logs -f` via WebSocket
|
||||||
|
3. **Compose file editor** : Édition avec validation en temps réel
|
||||||
|
4. **One-click deploy** : Upload et déploiement de stacks docker-compose
|
||||||
|
5. **Auto-update** : Vérification et mise à jour automatique des images (comme Watchtower)
|
||||||
|
|
||||||
|
### 5.5 CI/CD Self-Hosted
|
||||||
|
|
||||||
|
**Pipeline proposée pour le dashboard lui-même :**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐
|
||||||
|
│ Push │ → │ Lint & │ → │ Build │ → │ Deploy │
|
||||||
|
│ Git │ │ Test │ │ Docker │ │ Auto │
|
||||||
|
└─────────┘ └──────────┘ └─────────┘ └──────────┘
|
||||||
|
│ │ │ │
|
||||||
|
└──── Gitea/Forgejo ──── ── GitHub Actions ────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pour les services managés du homelab :**
|
||||||
|
- Interface de déploiement "GitOps-like"
|
||||||
|
- Rollback automatique si health check échoue
|
||||||
|
- Historique de déploiement avec diff
|
||||||
|
|
||||||
|
### 5.6 Multi-Tenant & Multi-User
|
||||||
|
|
||||||
|
| Fonctionnalité | Description |
|
||||||
|
|---------------|-------------|
|
||||||
|
| **Rôles granulaires** | Viewer, Operator, Admin avec permissions par section |
|
||||||
|
| **Audit trail** | Log de toutes les actions utilisateur avec IP/timestamp |
|
||||||
|
| **Workspaces** | Séparer les environnements (prod, staging, lab) |
|
||||||
|
| **API tokens** | Tokens scoped avec date d'expiration |
|
||||||
|
| **2FA** | Authentification à deux facteurs (TOTP) via un app authenticator |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Feuille de Route Actionnable 🗺️
|
||||||
|
|
||||||
|
### Phase 1 : Gains Rapides (1-2 semaines) 🏃
|
||||||
|
|
||||||
|
*Objectif : Corriger les failles critiques et les quick wins sans refactoring majeur.*
|
||||||
|
|
||||||
|
| # | Action | Effort | Impact | Fichier(s) |
|
||||||
|
|---|--------|--------|--------|-----------|
|
||||||
|
| 1.1 | ⚠️ **Ajouter `.env` au `.gitignore`** et créer `.env.example` | 5 min | 🔴 Critique | `.gitignore`, `.env.example` |
|
||||||
|
| 1.2 | 🔒 **Authentifier le WebSocket `/ws`** avec token JWT | 2h | 🔴 Haute | `routes/websocket.py` |
|
||||||
|
| 1.3 | 🐛 **Corriger `require_admin`** (champ `role` mal lu) | 15 min | 🔴 Haute | `core/dependencies.py` |
|
||||||
|
| 1.4 | 🔄 **Remplacer `threading.Lock` par `asyncio.Lock`** dans WebSocketManager | 30 min | 🟡 Moyenne | `services/websocket_service.py` |
|
||||||
|
| 1.5 | 🐛 **Corriger `readexactly` → `read`** dans le proxy terminal | 5 min | 🟡 Moyenne | `routes/websocket.py` |
|
||||||
|
| 1.6 | ⚡ **Wrapper `bootstrap_host` avec `asyncio.to_thread`** | 30 min | 🟠 Haute | `routes/bootstrap.py` |
|
||||||
|
| 1.7 | 📝 **Remplacer `print()` par `logging`** dans factory.py | 1h | 🟢 Basse | `factory.py` |
|
||||||
|
| 1.8 | 🔑 **Générer un JWT_SECRET_KEY aléatoire** au premier démarrage si non défini | 1h | 🔴 Haute | `services/auth_service.py` |
|
||||||
|
|
||||||
|
### Phase 2 : Objectifs à Moyen Terme (1-2 mois) 🎯
|
||||||
|
|
||||||
|
*Objectif : Consolider l'architecture et améliorer l'expérience utilisateur.*
|
||||||
|
|
||||||
|
| # | Action | Effort | Impact |
|
||||||
|
|---|--------|--------|--------|
|
||||||
|
| 2.1 | 🗑️ **Supprimer `app_optimized.py`** et le `HybridDB` | 2-3j | Architecture |
|
||||||
|
| 2.2 | 🔐 **Implémenter refresh tokens** + blacklist de tokens | 2j | Sécurité |
|
||||||
|
| 2.3 | 📊 **Indexer les logs de tâches en base** (remplacer le scan filesystem) | 2j | Performance |
|
||||||
|
| 2.4 | ⚡ **Optimiser la collecte Docker** (commandes SSH combinées) | 1j | Performance |
|
||||||
|
| 2.5 | 🎨 **Implémenter le système de toast notifications** | 2j | UI/UX |
|
||||||
|
| 2.6 | 🖥️ **Ajouter le split-pane et les onglets** au terminal | 3j | UI/UX |
|
||||||
|
| 2.7 | 🔔 **Heartbeat WebSocket** avec reconnexion automatique côté JS | 1j | Fiabilité |
|
||||||
|
| 2.8 | 📦 **Migrer vers FastAPI `lifespan`** (remplacer `on_event`) | 1j | Architecture |
|
||||||
|
| 2.9 | 🧪 **Augmenter la couverture de tests à 65%** (focus : auth, scheduler, WebSocket) | 3-5j | Qualité |
|
||||||
|
| 2.10 | 🗜️ **Activer la compression gzip** et les headers de cache statique | 2h | Performance |
|
||||||
|
| 2.11 | 📱 **Améliorer le responsive** (breakpoints tablette + mobile) | 2j | UI/UX |
|
||||||
|
| 2.12 | ⚙️ **Rendre configurables** les intervalles Docker et les limites de pagination | 1j | Ops |
|
||||||
|
|
||||||
|
### Phase 3 : Vision à Long Terme (3-6 mois) 🔭
|
||||||
|
|
||||||
|
*Objectif : Transformer le dashboard en plateforme homelab de référence.*
|
||||||
|
|
||||||
|
| # | Action | Effort | Impact |
|
||||||
|
|---|--------|--------|--------|
|
||||||
|
| 3.1 | 🔐 **Secrets Manager** — Chiffrement des secrets en base + UI | 1-2 sem | Sécurité |
|
||||||
|
| 3.2 | 🖥️ **Intégration Proxmox** — API + Dashboard VMs | 2-3 sem | Feature |
|
||||||
|
| 3.3 | 📊 **Monitoring avancé** — Graphiques historiques + anomaly detection | 2-3 sem | Feature |
|
||||||
|
| 3.4 | 🐳 **Docker Compose Manager** — Stacks + logs streaming + deploy | 2-3 sem | Feature |
|
||||||
|
| 3.5 | 👥 **Multi-user avec RBAC** — Rôles granulaires + audit trail | 2 sem | Sécurité |
|
||||||
|
| 3.6 | 🔑 **2FA (TOTP)** — Authentification à deux facteurs | 1 sem | Sécurité |
|
||||||
|
| 3.7 | 🚀 **CI/CD Pipeline** — Auto-deploy du dashboard + rollback | 2 sem | DevOps |
|
||||||
|
| 3.8 | 🔄 **Migration PostgreSQL** (optionnel) — Pour scalabilité multi-instance | 1-2 sem | Architecture |
|
||||||
|
| 3.9 | 📱 **PWA** — Notifications push natives, mode offline partiel | 1-2 sem | UI/UX |
|
||||||
|
| 3.10 | 🔌 **Plugin System** — Architecture extensible pour intégrations tierces | 3-4 sem | Architecture |
|
||||||
|
|
||||||
|
### Matrice de Prioritisation
|
||||||
|
|
||||||
|
```
|
||||||
|
IMPACT ÉLEVÉ
|
||||||
|
↑
|
||||||
|
┌────────┼────────┐
|
||||||
|
│ 1.1 │ 2.1 │
|
||||||
|
│ 1.2 │ 2.2 │
|
||||||
|
│ 1.3 │ 2.3 │
|
||||||
|
│ 1.6 │ 3.1 │
|
||||||
|
│ 1.8 │ 3.5 │
|
||||||
|
├────────┼────────┤
|
||||||
|
│ 1.4 │ 2.6 │
|
||||||
|
│ 1.5 │ 2.9 │
|
||||||
|
│ 1.7 │ 3.2 │
|
||||||
|
│ 2.10 │ 3.4 │
|
||||||
|
│ 2.7 │ 3.10 │
|
||||||
|
└────────┼────────┘
|
||||||
|
EFFORT FAIBLE EFFORT ÉLEVÉ
|
||||||
|
↓
|
||||||
|
IMPACT FAIBLE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Annexe A : Métriques du Projet
|
||||||
|
|
||||||
|
| Métrique | Valeur |
|
||||||
|
|----------|--------|
|
||||||
|
| Fichiers Python | 144 |
|
||||||
|
| Fichiers JS | 8 (app) |
|
||||||
|
| Fichier HTML | 1 (monolithique) |
|
||||||
|
| Migrations Alembic | 19 |
|
||||||
|
| Routes API | 22 routers |
|
||||||
|
| Modèles SQLAlchemy | 20 |
|
||||||
|
| Schemas Pydantic | 20 |
|
||||||
|
| Services | 16 |
|
||||||
|
| CRUD Repositories | 14+ |
|
||||||
|
| Tests backend | ~45% coverage |
|
||||||
|
| Tests frontend | Présents (vitest) |
|
||||||
|
| Taille `app_optimized.py` | 6585 lignes ⚠️ |
|
||||||
|
| Taille `index.html` | 247 KB ⚠️ |
|
||||||
|
| Taille `main.js` | 617 KB ⚠️ |
|
||||||
|
|
||||||
|
## Annexe B : Sécurité — Checklist de Déploiement Production
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] .env absent du repository Git
|
||||||
|
[ ] JWT_SECRET_KEY est une chaîne aléatoire de 64+ caractères
|
||||||
|
[ ] API_KEY est unique et complexe
|
||||||
|
[ ] HTTPS activé (reverse proxy nginx/traefik)
|
||||||
|
[ ] CORS restreint aux origines autorisées
|
||||||
|
[ ] WebSocket authentifié par JWT
|
||||||
|
[ ] Rate limiting activé sur /api/auth et /api/bootstrap
|
||||||
|
[ ] Logs de sécurité activés (tentatives de connexion, accès refusés)
|
||||||
|
[ ] Rotation des logs configurée
|
||||||
|
[ ] Backups de la base de données automatisés
|
||||||
|
[ ] SSH key permissions = 600
|
||||||
|
[ ] StrictHostKeyChecking configurable (pas hardcodé à "no")
|
||||||
|
[ ] Docker socket non exposé directement
|
||||||
|
[ ] ttyd bind sur localhost uniquement (si reverse proxy)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **📌 Note finale :** Ce projet est remarquablement bien structuré pour un projet homelab. L'architecture modulaire avec Factory Pattern, Repository Pattern, et la séparation claire des responsabilités témoignent d'une excellente compréhension des patterns d'architecture logicielle. Les correctifs et améliorations proposés dans ce rapport visent à renforcer une base déjà solide pour atteindre un niveau Enterprise-grade tout en conservant la flexibilité et l'agilité qui font le charme d'un projet homelab.
|
||||||
|
|
||||||
|
*Rapport généré le 20 février 2026 — Homelab Automation Dashboard v2.0.0*
|
||||||
80
documentation/refactoring_status_report.md
Normal file
80
documentation/refactoring_status_report.md
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
# Rapport d'Avancement de la Refonte — Homelab Automation API v2
|
||||||
|
|
||||||
|
**Date :** 3 Mars 2026
|
||||||
|
**Statut :** En cours (11 actions complétées)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. RÉSUMÉ EXÉCUTIF
|
||||||
|
|
||||||
|
Ce rapport documente les progrès réalisés dans la refonte de l'application **homelab-automation-api-v2**. L'objectif principal est de corriger des vulnérabilités de sécurité critiques (OWASP), d'améliorer l'architecture modulaire et d'optimiser les performances via des mécanismes de mise en cache.
|
||||||
|
|
||||||
|
À ce jour, **toutes les actions prioritaires P0 (Critiques)** ont été traitées, ainsi que la majorité des actions **P1 (Majeures)** et **P2 (Mineures)**. Les fondations de sécurité sont désormais robustes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ACTIONS COMPLÉTÉES (TERMINÉES)
|
||||||
|
|
||||||
|
### 🔴 Priorité P0 — Critique (100% complété)
|
||||||
|
|
||||||
|
| Action | Description | Impact |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| **Correction Bug RBAC** | Correction de la fonction `require_admin` dans `dependencies.py`. Le rôle est désormais correctement récupéré depuis le JWT. | Sécurisation totale des accès administrateur. |
|
||||||
|
| **Gestion des Secrets** | Création de `.env.example` et suppression de toutes les valeurs secrètes par défaut dans `config.py` et `auth_service.py`. | Élimine les risques de fuite de credentials par défaut. |
|
||||||
|
| **Isolation .env** | Les variables sensibles sont désormais strictement gérées hors du code source (via variables d'environnement). | Conformité avec les bonnes pratiques DevOps. |
|
||||||
|
|
||||||
|
### 🟠 Priorité P1 — Majeur (75% complété)
|
||||||
|
|
||||||
|
| Action | Description | Impact |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| **Rate Limiting** | Implémentation de `slowapi` sur les endpoints d'authentification (`/login`, `/setup`). | Protection contre les attaques par force brute. |
|
||||||
|
| **Sécurisation CORS** | Restriction des origines autorisées via la variable `CORS_ORIGINS` (suppression du wildcard `*`). | Prévention des attaques CSRF. |
|
||||||
|
| **Unification Configuration** | `auth_service.py` utilise désormais le singleton `settings` comme source unique de vérité. | Cohérence et maintenabilité de la configuration. |
|
||||||
|
|
||||||
|
### 🟡 Priorité P2 — Mineur (75% complété)
|
||||||
|
|
||||||
|
| Action | Description | Impact |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| **Nettoyage Dead Code** | Suppression du fichier monolithique obsolète `app_optimized.py` (255 KB). | Allègement du codebase et clarté technique. |
|
||||||
|
| **Migration PyJWT** | Passage de `python-jose` (déprécié) vers `PyJWT` (maintenu). | Sécurité et pérennité des dépendances. |
|
||||||
|
| **Validation Mots de Passe** | Renforcement des critères (min 8 chars, majuscule, minuscule, chiffre, caractère spécial). | Amélioration de la robustesse des comptes utilisateurs. |
|
||||||
|
|
||||||
|
### 🔵 Priorité P3 — Améliorations (50% complété)
|
||||||
|
|
||||||
|
| Action | Description | Impact |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| **Mise en cache (TTLCache)** | Intégration de `cachetools.TTLCache` dans `AnsibleService` pour l'inventaire et les playbooks. | Réduction des accès disque/CPU lors des lectures d'inventaire. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ACTIONS RESTANTES (À FAIRE)
|
||||||
|
|
||||||
|
### 🟠 Priorité P1 — Majeur (Restant)
|
||||||
|
- **Extraction HTML Terminal** : Extraire les 1 200+ lignes d'HTML inline du fichier `terminal.py` vers des templates Jinja2 (`app/templates/terminal/`).
|
||||||
|
- *Raison du report : Tâche massive de refactorisation mécanique nécessitant une session dédiée.*
|
||||||
|
|
||||||
|
### 🟡 Priorité P2 — Mineur (Restant)
|
||||||
|
- **Restructuration Frontend** : Déplacer les fichiers `main.js`, `index.html` et autres assets dans un dossier `app/static/` dédié.
|
||||||
|
- *Nécessite la mise à jour des routes statiques et des références de fichiers.*
|
||||||
|
|
||||||
|
### 🔵 Priorité P3 — Améliorations (Restant)
|
||||||
|
- **Injection de Dépendances (DI)** : Migrer les singletons globaux vers l'injection de dépendances native de FastAPI pour améliorer la testabilité.
|
||||||
|
- **Intégrations OpenClaw (IA)** :
|
||||||
|
1. **Agent d'Auto-Remédiation** : Réaction automatique aux alertes métriques.
|
||||||
|
2. **Assistant Playbook** : Aide à la rédaction de YAML Ansible via IA.
|
||||||
|
3. **Monitoring Prédictif** : Analyse des tendances pour prédire les pannes.
|
||||||
|
|
||||||
|
### ⚪ Priorité P4 — Scalabilité & Architecture (Futur)
|
||||||
|
- **Migration Base de Données** : Finaliser le passage de SQLite vers MySQL ou PostgreSQL pour la production.
|
||||||
|
- **Ansible Asynchrone** : Implémenter une file d'attente (arq, Celery ou TaskIQ) pour les exécutions Ansible afin de ne pas bloquer l'API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. PROCHAINES ÉTAPES RECOMMANDÉES
|
||||||
|
|
||||||
|
1. **Extraction Terminal HTML** : C'est la prochaine priorité haute pour "nettoyer" le code backend le plus lourd.
|
||||||
|
2. **Setup Environnement de Test** : Utiliser les nouveaux validateurs de mot de passe et le rate-limiting pour vérifier le comportement en conditions réelles.
|
||||||
|
3. **PoC Auto-Remédiation** : Commencer l'intégration OpenClaw avec un cas d'usage simple (ex: nettoyage automatique d'un disque saturé).
|
||||||
|
|
||||||
|
---
|
||||||
|
*Rapport généré par Antigravity — Architecte Logiciel Senior.*
|
||||||
Loading…
x
Reference in New Issue
Block a user