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

This commit is contained in:
Bruno Charest 2026-03-03 08:29:52 -05:00
parent 21d99a0f48
commit 88742892d0
18 changed files with 3016 additions and 9085 deletions

69
.env.example Normal file
View 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

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -833,7 +833,7 @@ const containersPage = {
const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length; const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length;
this.showToast(`${successful}/${this.selectedIds.size} container(s) ${action}`, this.showToast(`${successful}/${this.selectedIds.size} container(s) ${action}`,
successful === this.selectedIds.size ? 'success' : 'warning'); successful === this.selectedIds.size ? 'success' : 'warning');
this.selectedIds.clear(); this.selectedIds.clear();
this.updateBulkActionsBar(); this.updateBulkActionsBar();
@ -874,7 +874,7 @@ const containersPage = {
} }
if (selectAll) { if (selectAll) {
selectAll.checked = this.selectedIds.size > 0 && selectAll.checked = this.selectedIds.size > 0 &&
this.selectedIds.size === this.filteredContainers.length; this.selectedIds.size === this.filteredContainers.length;
} }
}, },
@ -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();

View File

@ -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=["*"])

View File

@ -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(

View File

@ -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")

View File

@ -164,10 +164,10 @@ class DashboardManager {
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
${palette.map(c => { ${palette.map(c => {
const label = c || 'Aucune'; const label = c || 'Aucune';
const style = c ? `background:${c}` : 'background:transparent'; const style = c ? `background:${c}` : 'background:transparent';
return `<button type="button" onclick="dashboard.setContainerCustomBgColor('${c}')" class="w-7 h-7 rounded border border-gray-600 hover:border-gray-400 transition-colors" style="${style}" title="${label}"></button>`; return `<button type="button" onclick="dashboard.setContainerCustomBgColor('${c}')" class="w-7 h-7 rounded border border-gray-600 hover:border-gray-400 transition-colors" style="${style}" title="${label}"></button>`;
}).join('')} }).join('')}
</div> </div>
</div> </div>
`; `;
@ -872,8 +872,8 @@ class DashboardManager {
const serverMessage = const serverMessage =
(errorDetail && (errorDetail.detail || errorDetail.message || errorDetail.error)) (errorDetail && (errorDetail.detail || errorDetail.message || errorDetail.error))
? (errorDetail.detail || errorDetail.message || errorDetail.error) ? (errorDetail.detail || errorDetail.message || errorDetail.error)
: response.statusText; : response.statusText;
const err = new Error(`HTTP ${response.status}: ${serverMessage || 'Erreur inconnue'}`); const err = new Error(`HTTP ${response.status}: ${serverMessage || 'Erreur inconnue'}`);
err.status = response.status; err.status = response.status;
@ -1721,8 +1721,8 @@ class DashboardManager {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const isToday = date.getFullYear() === today.getFullYear() && const isToday = date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() && date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate(); date.getDate() === today.getDate();
let classes = 'w-9 h-9 flex items-center justify-center rounded-full text-xs transition-colors duration-150 '; let classes = 'w-9 h-9 flex items-center justify-center rounded-full text-xs transition-colors duration-150 ';
@ -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('')}
@ -2164,7 +2164,7 @@ class DashboardManager {
const lastCollected = metrics.last_collected const lastCollected = metrics.last_collected
? new Date(metrics.last_collected).toLocaleString('fr-FR', { ? new Date(metrics.last_collected).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
}) })
: 'N/A'; : 'N/A';
// CPU gauge // CPU gauge
@ -2621,8 +2621,8 @@ class DashboardManager {
<span class="text-[10px] text-gray-600">Used / Free / Total</span> <span class="text-[10px] text-gray-600">Used / Free / Total</span>
</div> </div>
${(diskCards || mountCards) ${(diskCards || mountCards)
? `<div class="grid grid-cols-1 gap-2">${diskCards || mountCards}</div>` ? `<div class="grid grid-cols-1 gap-2">${diskCards || mountCards}</div>`
: `<div class="text-[11px] text-gray-500">Aucun détail disponible</div>`} : `<div class="text-[11px] text-gray-500">Aucun détail disponible</div>`}
${renderLvmSection()} ${renderLvmSection()}
${renderZfsSection()} ${renderZfsSection()}
</div> </div>
@ -2756,7 +2756,7 @@ class DashboardManager {
const mp = (fs.mountpoint || '').toLowerCase(); const mp = (fs.mountpoint || '').toLowerCase();
const dev = (fs.device || '').toLowerCase(); const dev = (fs.device || '').toLowerCase();
return !mp.startsWith('/run') && !mp.startsWith('/sys') && !mp.startsWith('/proc') && return !mp.startsWith('/run') && !mp.startsWith('/sys') && !mp.startsWith('/proc') &&
!dev.includes('tmpfs') && !dev.includes('devtmpfs'); !dev.includes('tmpfs') && !dev.includes('devtmpfs');
}); });
totalBytes = realFs.reduce((sum, fs) => sum + (fs.size_bytes || 0), 0); totalBytes = realFs.reduce((sum, fs) => sum + (fs.size_bytes || 0), 0);
usedBytes = realFs.reduce((sum, fs) => sum + (fs.used_bytes || 0), 0); usedBytes = realFs.reduce((sum, fs) => sum + (fs.used_bytes || 0), 0);
@ -2778,7 +2778,7 @@ class DashboardManager {
const sizeLine = totalBytes > 0 ? `${formatBytes(usedBytes)} / ${formatBytes(totalBytes)}` : ''; const sizeLine = totalBytes > 0 ? `${formatBytes(usedBytes)} / ${formatBytes(totalBytes)}` : '';
const statusBadge = status === 'ok' ? 'bg-green-500/20 text-green-400' : const statusBadge = status === 'ok' ? 'bg-green-500/20 text-green-400' :
status === 'partial' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-red-500/20 text-red-400'; status === 'partial' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-red-500/20 text-red-400';
const statusText = status === 'ok' ? 'OK' : status === 'partial' ? 'Partiel' : 'Erreur'; const statusText = status === 'ok' ? 'OK' : status === 'partial' ? 'Partiel' : 'Erreur';
const getPctColor = (pct) => { const getPctColor = (pct) => {
@ -2794,7 +2794,7 @@ class DashboardManager {
const mp = (fs.mountpoint || '').toLowerCase(); const mp = (fs.mountpoint || '').toLowerCase();
const dev = (fs.device || '').toLowerCase(); const dev = (fs.device || '').toLowerCase();
return !mp.startsWith('/run') && !mp.startsWith('/sys') && !mp.startsWith('/proc') && return !mp.startsWith('/run') && !mp.startsWith('/sys') && !mp.startsWith('/proc') &&
!dev.includes('tmpfs') && !dev.includes('devtmpfs'); !dev.includes('tmpfs') && !dev.includes('devtmpfs');
}); });
return ` return `
@ -2812,14 +2812,14 @@ class DashboardManager {
</thead> </thead>
<tbody> <tbody>
${filtered.slice(0, 20).map(fs => { ${filtered.slice(0, 20).map(fs => {
const pct = fs.use_pct !== undefined ? Number(fs.use_pct) : null; const pct = fs.use_pct !== undefined ? Number(fs.use_pct) : null;
const pctColor = pct >= 90 ? 'text-red-400' : pct >= 75 ? 'text-yellow-400' : 'text-green-400'; const pctColor = pct >= 90 ? 'text-red-400' : pct >= 75 ? 'text-yellow-400' : 'text-green-400';
const rowClass = pct >= 85 ? 'bg-red-500/10' : ''; const rowClass = pct >= 85 ? 'bg-red-500/10' : '';
const rawDevice = (fs.device || '-'); const rawDevice = (fs.device || '-');
const deviceDisplay = (typeof rawDevice === 'string' && rawDevice.startsWith('/dev/')) const deviceDisplay = (typeof rawDevice === 'string' && rawDevice.startsWith('/dev/'))
? rawDevice.slice('/dev/'.length) ? rawDevice.slice('/dev/'.length)
: rawDevice; : rawDevice;
return ` return `
<tr class="border-b border-gray-800 hover:bg-gray-800/50 ${rowClass}"> <tr class="border-b border-gray-800 hover:bg-gray-800/50 ${rowClass}">
<td class="py-1 px-2 text-gray-300 truncate max-w-[150px]" title="${this.escapeHtml(fs.mountpoint || '')}">${this.escapeHtml(fs.mountpoint || '-')}</td> <td class="py-1 px-2 text-gray-300 truncate max-w-[150px]" title="${this.escapeHtml(fs.mountpoint || '')}">${this.escapeHtml(fs.mountpoint || '-')}</td>
<td class="py-1 px-2 text-gray-400 truncate max-w-[120px]" title="${this.escapeHtml(deviceDisplay || '')}">${this.escapeHtml(deviceDisplay || '-')}</td> <td class="py-1 px-2 text-gray-400 truncate max-w-[120px]" title="${this.escapeHtml(deviceDisplay || '')}">${this.escapeHtml(deviceDisplay || '-')}</td>
@ -2829,7 +2829,7 @@ class DashboardManager {
<td class="py-1 px-2 text-right font-medium ${pctColor}">${pct !== null ? pct + '%' : '-'}</td> <td class="py-1 px-2 text-right font-medium ${pctColor}">${pct !== null ? pct + '%' : '-'}</td>
</tr> </tr>
`; `;
}).join('')} }).join('')}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -2843,7 +2843,7 @@ class DashboardManager {
const pct = pool.cap_pct !== undefined ? Number(pool.cap_pct) : null; const pct = pool.cap_pct !== undefined ? Number(pool.cap_pct) : null;
const color = getPctColor(pct); const color = getPctColor(pct);
const healthColor = pool.health === 'ONLINE' ? 'text-green-400' : const healthColor = pool.health === 'ONLINE' ? 'text-green-400' :
pool.health === 'DEGRADED' ? 'text-yellow-400' : 'text-red-400'; pool.health === 'DEGRADED' ? 'text-yellow-400' : 'text-red-400';
return ` return `
<div class="p-2 rounded bg-gray-900/50 border border-gray-700/50"> <div class="p-2 rounded bg-gray-900/50 border border-gray-700/50">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@ -3926,9 +3926,9 @@ class DashboardManager {
</label> </label>
<select id="move-hosts-to" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none"> <select id="move-hosts-to" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none">
${otherGroups.length > 0 ${otherGroups.length > 0
? otherGroups.map(g => `<option value="${g.name}">${g.display_name}</option>`).join('') ? otherGroups.map(g => `<option value="${g.name}">${g.display_name}</option>`).join('')
: '<option value="" disabled>Aucun autre groupe disponible</option>' : '<option value="" disabled>Aucun autre groupe disponible</option>'
} }
</select> </select>
<p class="text-xs text-gray-400 mt-1">Les ${hostsCount} hôte(s) seront déplacés vers ce groupe.</p> <p class="text-xs text-gray-400 mt-1">Les ${hostsCount} hôte(s) seront déplacés vers ce groupe.</p>
</div> </div>
@ -4158,9 +4158,9 @@ class DashboardManager {
// Vérifier si des filtres sont actifs // Vérifier si des filtres sont actifs
const hasActiveFilters = (this.currentTargetFilter && this.currentTargetFilter !== 'all') || const hasActiveFilters = (this.currentTargetFilter && this.currentTargetFilter !== 'all') ||
(this.currentCategoryFilter && this.currentCategoryFilter !== 'all') || (this.currentCategoryFilter && this.currentCategoryFilter !== 'all') ||
(this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') || (this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') ||
(this.currentHourStart || this.currentHourEnd); (this.currentHourStart || this.currentHourEnd);
// Labels pour les types de source // Labels pour les types de source
const sourceTypeLabels = { scheduled: 'Planifiés', manual: 'Manuels', adhoc: 'Ad-hoc' }; const sourceTypeLabels = { scheduled: 'Planifiés', manual: 'Manuels', adhoc: 'Ad-hoc' };
@ -4443,11 +4443,10 @@ 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">
${this.escapeHtml(log.category)}${log.subcategory ? ` / ${this.escapeHtml(log.subcategory)}` : ''} ${this.escapeHtml(log.category)}${log.subcategory ? ` / ${this.escapeHtml(log.subcategory)}` : ''}
</span>` </span>`
: ''; : '';
@ -4633,17 +4632,17 @@ class DashboardManager {
</div> </div>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
${hostOutputs.map((host, index) => { ${hostOutputs.map((host, index) => {
const hostStatusColor = (host.status === 'changed' || host.status === 'success') const hostStatusColor = (host.status === 'changed' || host.status === 'success')
? 'bg-green-600/80 hover:bg-green-500 border-green-500' ? 'bg-green-600/80 hover:bg-green-500 border-green-500'
: (host.status === 'failed' || host.status === 'unreachable') : (host.status === 'failed' || host.status === 'unreachable')
? 'bg-red-600/80 hover:bg-red-500 border-red-500' ? 'bg-red-600/80 hover:bg-red-500 border-red-500'
: 'bg-gray-600/80 hover:bg-gray-500 border-gray-500'; : 'bg-gray-600/80 hover:bg-gray-500 border-gray-500';
const hostStatusIcon = (host.status === 'changed' || host.status === 'success') const hostStatusIcon = (host.status === 'changed' || host.status === 'success')
? 'fa-check' ? 'fa-check'
: (host.status === 'failed' || host.status === 'unreachable') : (host.status === 'failed' || host.status === 'unreachable')
? 'fa-times' ? 'fa-times'
: 'fa-minus'; : 'fa-minus';
return ` return `
<button type="button" <button type="button"
onclick="dashboard.switchTaskLogHostTab(${index})" onclick="dashboard.switchTaskLogHostTab(${index})"
data-tasklog-host-tab="${index}" data-tasklog-host-tab="${index}"
@ -4652,7 +4651,7 @@ class DashboardManager {
<span class="truncate max-w-[140px]">${this.escapeHtml(host.hostname)}</span> <span class="truncate max-w-[140px]">${this.escapeHtml(host.hostname)}</span>
</button> </button>
`; `;
}).join('')} }).join('')}
</div> </div>
</div> </div>
`; `;
@ -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;
@ -5732,11 +5731,11 @@ class DashboardManager {
<span class="text-xs text-gray-500">${task.hostResults.length} hôte(s)</span> <span class="text-xs text-gray-500">${task.hostResults.length} hôte(s)</span>
<div class="flex -space-x-1"> <div class="flex -space-x-1">
${task.hostResults.slice(0, 5).map(r => { ${task.hostResults.slice(0, 5).map(r => {
const dotColor = r.status === 'ok' ? 'bg-green-500' : const dotColor = r.status === 'ok' ? 'bg-green-500' :
r.status === 'changed' ? 'bg-yellow-500' : r.status === 'changed' ? 'bg-yellow-500' :
r.status === 'failed' || r.status === 'fatal' ? 'bg-red-500' : 'bg-gray-500'; r.status === 'failed' || r.status === 'fatal' ? 'bg-red-500' : 'bg-gray-500';
return `<div class="w-2 h-2 rounded-full ${dotColor} border border-gray-900"></div>`; return `<div class="w-2 h-2 rounded-full ${dotColor} border border-gray-900"></div>`;
}).join('')} }).join('')}
${task.hostResults.length > 5 ? `<span class="text-[10px] text-gray-500 ml-1">+${task.hostResults.length - 5}</span>` : ''} ${task.hostResults.length > 5 ? `<span class="text-[10px] text-gray-500 ml-1">+${task.hostResults.length - 5}</span>` : ''}
</div> </div>
</div> </div>
@ -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;
@ -8380,8 +8379,8 @@ class DashboardManager {
<i class="fas fa-file-code text-4xl mb-4 opacity-50"></i> <i class="fas fa-file-code text-4xl mb-4 opacity-50"></i>
<p class="mb-2">Aucun playbook trouvé</p> <p class="mb-2">Aucun playbook trouvé</p>
${this.currentPlaybookSearch || this.currentPlaybookCategoryFilter !== 'all' ${this.currentPlaybookSearch || this.currentPlaybookCategoryFilter !== 'all'
? '<p class="text-sm">Essayez de modifier vos filtres</p>' ? '<p class="text-sm">Essayez de modifier vos filtres</p>'
: '<button onclick="dashboard.showCreatePlaybookModal()" class="mt-4 px-4 py-2 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors"><i class="fas fa-plus mr-2"></i>Créer un playbook</button>'} : '<button onclick="dashboard.showCreatePlaybookModal()" class="mt-4 px-4 py-2 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors"><i class="fas fa-plus mr-2"></i>Créer un playbook</button>'}
</div> </div>
`; `;
return; return;
@ -9429,8 +9428,8 @@ class DashboardManager {
const dc = f.docker_container; const dc = f.docker_container;
if (!dc) return false; if (!dc) return false;
return (dc.name || '').toLowerCase().includes(searchTerm) || return (dc.name || '').toLowerCase().includes(searchTerm) ||
(dc.host_name || '').toLowerCase().includes(searchTerm) || (dc.host_name || '').toLowerCase().includes(searchTerm) ||
(dc.image || '').toLowerCase().includes(searchTerm); (dc.image || '').toLowerCase().includes(searchTerm);
}); });
} }
@ -9541,29 +9540,29 @@ class DashboardManager {
</summary> </summary>
<div class="divide-y divide-gray-700/50"> <div class="divide-y divide-gray-700/50">
${items.map(f => { ${items.map(f => {
const dc = f.docker_container; const dc = f.docker_container;
if (!dc) return ''; if (!dc) return '';
const hostStatus = (dc.host_docker_status || '').toLowerCase(); const hostStatus = (dc.host_docker_status || '').toLowerCase();
const hostOffline = hostStatus && hostStatus !== 'online'; const hostOffline = hostStatus && hostStatus !== 'online';
const dotColor = stateColor(dc.state); const dotColor = stateColor(dc.state);
const isRunning = String(dc.state || '').toLowerCase() === 'running'; const isRunning = String(dc.state || '').toLowerCase() === 'running';
const favId = f.id; const favId = f.id;
const disabledClass = hostOffline ? 'opacity-50 cursor-not-allowed' : ''; const disabledClass = hostOffline ? 'opacity-50 cursor-not-allowed' : '';
const btnDisabled = hostOffline ? 'disabled' : ''; const btnDisabled = hostOffline ? 'disabled' : '';
const healthBadge = dc.health && dc.health !== 'none' const healthBadge = dc.health && dc.health !== 'none'
? `<span class="px-2 py-0.5 rounded text-xs bg-${dc.health === 'healthy' ? 'green' : 'red'}-500/20 text-${dc.health === 'healthy' ? 'green' : 'red'}-400">${this.escapeHtml(dc.health)}</span>` ? `<span class="px-2 py-0.5 rounded text-xs bg-${dc.health === 'healthy' ? 'green' : 'red'}-500/20 text-${dc.health === 'healthy' ? 'green' : 'red'}-400">${this.escapeHtml(dc.health)}</span>`
: ''; : '';
const custom = window.containerCustomizationsManager?.get(dc.host_id, dc.container_id); const custom = window.containerCustomizationsManager?.get(dc.host_id, dc.container_id);
const iconKey = custom?.icon_key || ''; const iconKey = custom?.icon_key || '';
const iconColor = custom?.icon_color || '#9ca3af'; const iconColor = custom?.icon_color || '#9ca3af';
const bgColor = custom?.bg_color || ''; const bgColor = custom?.bg_color || '';
const bgStyle = bgColor ? `background:${this.escapeHtml(bgColor)};` : ''; const bgStyle = bgColor ? `background:${this.escapeHtml(bgColor)};` : '';
const iconHtml = iconKey const iconHtml = iconKey
? `<span class="w-5 h-5 rounded flex items-center justify-center border border-gray-700" style="${bgStyle}"><span class="iconify text-sm" data-icon="${this.escapeHtml(iconKey)}" style="color:${this.escapeHtml(iconColor)}"></span></span>` ? `<span class="w-5 h-5 rounded flex items-center justify-center border border-gray-700" style="${bgStyle}"><span class="iconify text-sm" data-icon="${this.escapeHtml(iconKey)}" style="color:${this.escapeHtml(iconColor)}"></span></span>`
: ''; : '';
return ` return `
<div class="px-3 py-2 flex items-center justify-between gap-3"> <div class="px-3 py-2 flex items-center justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
@ -9610,7 +9609,7 @@ class DashboardManager {
</div> </div>
</div> </div>
`; `;
}).join('')} }).join('')}
</div> </div>
</details> </details>
`; `;
@ -10186,12 +10185,12 @@ class DashboardManager {
</div> </div>
<div id="adhoc-detail-hosts" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-48 overflow-y-auto"> <div id="adhoc-detail-hosts" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
${hostsResults.map(hr => { ${hostsResults.map(hr => {
const hrColor = hr.status === 'SUCCESS' ? 'border-green-700/50 bg-green-900/20' : const hrColor = hr.status === 'SUCCESS' ? 'border-green-700/50 bg-green-900/20' :
hr.status === 'CHANGED' ? 'border-yellow-700/50 bg-yellow-900/20' : hr.status === 'CHANGED' ? 'border-yellow-700/50 bg-yellow-900/20' :
'border-red-700/50 bg-red-900/20'; 'border-red-700/50 bg-red-900/20';
const hrTextColor = hr.status === 'SUCCESS' ? 'text-green-400' : const hrTextColor = hr.status === 'SUCCESS' ? 'text-green-400' :
hr.status === 'CHANGED' ? 'text-yellow-400' : 'text-red-400'; hr.status === 'CHANGED' ? 'text-yellow-400' : 'text-red-400';
return ` return `
<div class="adhoc-host-result p-2 rounded border ${hrColor}" data-status="${hr.status.toLowerCase()}"> <div class="adhoc-host-result p-2 rounded border ${hrColor}" data-status="${hr.status.toLowerCase()}">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs font-medium text-white"><i class="fas fa-exchange-alt mr-1 ${hrTextColor}"></i>${this.escapeHtml(hr.host)}</span> <span class="text-xs font-medium text-white"><i class="fas fa-exchange-alt mr-1 ${hrTextColor}"></i>${this.escapeHtml(hr.host)}</span>
@ -10202,7 +10201,7 @@ class DashboardManager {
<div class="text-[10px] text-gray-500 mt-1 truncate">${this.escapeHtml(hr.line.substring(0, 60))}...</div> <div class="text-[10px] text-gray-500 mt-1 truncate">${this.escapeHtml(hr.line.substring(0, 60))}...</div>
</div> </div>
`; `;
}).join('')} }).join('')}
</div> </div>
</div> </div>
` : ''} ` : ''}
@ -10339,8 +10338,8 @@ class DashboardManager {
let lastRunHtml = ''; let lastRunHtml = '';
if (schedule.last_run_at) { if (schedule.last_run_at) {
const lastStatusIcon = schedule.last_status === 'success' ? '✅' : const lastStatusIcon = schedule.last_status === 'success' ? '✅' :
schedule.last_status === 'failed' ? '❌' : schedule.last_status === 'failed' ? '❌' :
schedule.last_status === 'running' ? '🔄' : ''; schedule.last_status === 'running' ? '🔄' : '';
const lastRunDate = new Date(schedule.last_run_at); const lastRunDate = new Date(schedule.last_run_at);
lastRunHtml = `<span class="text-gray-500">| Dernier: ${lastStatusIcon} ${this.formatRelativeTime(lastRunDate)}</span>`; lastRunHtml = `<span class="text-gray-500">| Dernier: ${lastStatusIcon} ${this.formatRelativeTime(lastRunDate)}</span>`;
} }
@ -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('')}
@ -11228,15 +11227,15 @@ class DashboardManager {
</div> </div>
<div class="space-y-2 max-h-96 overflow-y-auto"> <div class="space-y-2 max-h-96 overflow-y-auto">
${runs.map(run => { ${runs.map(run => {
const startedAt = new Date(run.started_at); const startedAt = new Date(run.started_at);
const statusClass = run.status === 'success' ? 'success' : const statusClass = run.status === 'success' ? 'success' :
run.status === 'failed' ? 'failed' : run.status === 'failed' ? 'failed' :
run.status === 'running' ? 'running' : 'scheduled'; run.status === 'running' ? 'running' : 'scheduled';
const statusIcon = run.status === 'success' ? 'check-circle' : const statusIcon = run.status === 'success' ? 'check-circle' :
run.status === 'failed' ? 'times-circle' : run.status === 'failed' ? 'times-circle' :
run.status === 'running' ? 'spinner fa-spin' : 'clock'; run.status === 'running' ? 'spinner fa-spin' : 'clock';
return ` return `
<div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg"> <div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="schedule-status-chip ${statusClass}"> <span class="schedule-status-chip ${statusClass}">
@ -11253,7 +11252,7 @@ class DashboardManager {
</div> </div>
</div> </div>
`; `;
}).join('')} }).join('')}
</div> </div>
`; `;
} }
@ -11276,7 +11275,7 @@ class DashboardManager {
// Titre // Titre
const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
titleEl.textContent = `${monthNames[month]} ${year}`; titleEl.textContent = `${monthNames[month]} ${year}`;
// Premier jour du mois // Premier jour du mois
@ -11416,25 +11415,23 @@ 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>
<span>${message}</span> <span>${message}</span>
</div> </div>
`; `;
@ -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;
} }

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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."""

View File

@ -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

View 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>

View 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>

View 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*

View 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.*