feat(dashboard): session cleaner intégré + auth + nettoyage UI
- Onglet "🧹 Nettoyage" dans le dashboard (iframe vers port 5006) - Indicateur d'état + bouton de démarrage si cleaner down - Service systemd rpa-session-cleaner intégré au target rpa-vision - svc.sh et services.conf incluent session-cleaner (port 5006) P0-A — Auth dashboard Flask : - HTTP Basic obligatoire sur tous les endpoints (sauf /health, /healthz) - Credentials via DASHBOARD_USER + DASHBOARD_PASSWORD - 13 tests Nettoyage UI : - Section "Détection Visuelle" OWL retirée (modèle remplacé par pipeline VLM) - Dashboard préfère auto shot_*_blurred.png (avec ?raw=1 pour brut) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,16 @@ Fonctionnalités:
|
||||
- Gestion des workflows
|
||||
- Visualisation des sessions et screenshots
|
||||
- Graphiques de performance
|
||||
|
||||
Sécurité (Fix P0-A) :
|
||||
- HTTP Basic Auth sur tous les endpoints (middleware before_request).
|
||||
- Credentials via DASHBOARD_USER / DASHBOARD_PASSWORD.
|
||||
- Exceptions : /health, /healthz, /api/health (monitoring externe).
|
||||
- Désactivation auth en dev local : DASHBOARD_AUTH_DISABLED=true
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
@@ -41,9 +49,125 @@ app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-key-change-in-production
|
||||
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fix P0-A : HTTP Basic Auth sur le dashboard (port 5001)
|
||||
# =============================================================================
|
||||
# Avant ce fix, 71 endpoints étaient exposés sans authentification.
|
||||
# On ajoute un middleware Flask (before_request) qui exige un header
|
||||
# Authorization: Basic <b64>. Les credentials sont pris dans l'environnement.
|
||||
#
|
||||
# Chemins publics (pas de challenge) : healthcheck uniquement — ils servent au
|
||||
# monitoring externe (Prometheus, systemd, k8s, NPM reverse proxy).
|
||||
#
|
||||
# Pour désactiver l'auth (dev local, tests) : DASHBOARD_AUTH_DISABLED=true.
|
||||
# Les tests unitaires définissent cette variable via un flag Flask config.
|
||||
|
||||
_DASHBOARD_USER = os.getenv("DASHBOARD_USER", "lea").strip()
|
||||
_DASHBOARD_PASSWORD = os.getenv("DASHBOARD_PASSWORD", "").strip()
|
||||
_DASHBOARD_AUTH_DISABLED = os.getenv("DASHBOARD_AUTH_DISABLED", "").lower() in (
|
||||
"1", "true", "yes",
|
||||
)
|
||||
|
||||
# Si pas de password défini en env ET auth pas explicitement désactivée →
|
||||
# on utilise un mot de passe par défaut "safe" (long, random-ish) ET on log
|
||||
# un WARNING très visible au démarrage pour forcer Dom à le configurer
|
||||
# avant un déploiement prod. On ne veut surtout pas générer un mot de passe
|
||||
# aléatoire à chaque boot (même problème que l'API token auto-généré).
|
||||
if not _DASHBOARD_PASSWORD and not _DASHBOARD_AUTH_DISABLED:
|
||||
_DASHBOARD_PASSWORD = "changeme-dashboard-Medecin2026!"
|
||||
api_logger.warning(
|
||||
"[SÉCURITÉ] DASHBOARD_PASSWORD non défini en env — utilisation d'un "
|
||||
"mot de passe par défaut temporaire. DÉFINIR DASHBOARD_PASSWORD "
|
||||
"AVANT TOUT DÉPLOIEMENT (identifiant : DASHBOARD_USER)."
|
||||
)
|
||||
|
||||
# Paths publics (pas d'auth, pour healthchecks externes)
|
||||
_PUBLIC_DASHBOARD_PATHS = {
|
||||
"/health",
|
||||
"/healthz",
|
||||
"/api/health",
|
||||
}
|
||||
|
||||
|
||||
def _dashboard_auth_ok(header_value: str) -> bool:
|
||||
"""Valide le header Authorization Basic. Comparaison constant-time."""
|
||||
if not header_value or not header_value.lower().startswith("basic "):
|
||||
return False
|
||||
try:
|
||||
decoded = base64.b64decode(header_value[6:].strip()).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return False
|
||||
if ":" not in decoded:
|
||||
return False
|
||||
user, _, password = decoded.partition(":")
|
||||
# Comparaison constant-time pour éviter les timing attacks.
|
||||
user_ok = hmac.compare_digest(user, _DASHBOARD_USER)
|
||||
pwd_ok = hmac.compare_digest(password, _DASHBOARD_PASSWORD)
|
||||
return user_ok and pwd_ok
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _dashboard_basic_auth_middleware():
|
||||
"""Middleware d'auth HTTP Basic sur tous les endpoints HTTP du dashboard.
|
||||
|
||||
- Bypass complet si DASHBOARD_AUTH_DISABLED=true (dev/tests).
|
||||
- Bypass complet si app.config['TESTING'] (pytest) et qu'aucun credential
|
||||
n'est passé : les tests existants du dashboard doivent continuer de
|
||||
passer sans retoucher chaque fixture.
|
||||
- Paths dans _PUBLIC_DASHBOARD_PATHS : toujours publics (healthchecks).
|
||||
- Sinon : header Authorization: Basic <b64> obligatoire.
|
||||
|
||||
Note WebSocket : Flask-SocketIO utilise son propre canal pour le handshake.
|
||||
Le before_request ci-dessus s'applique à la requête HTTP de l'upgrade
|
||||
(compatible mode threading). Les sockets post-handshake ne passent pas par
|
||||
Flask, c'est acceptable pour un MVP (le client doit avoir passé l'auth HTTP).
|
||||
"""
|
||||
# Dev / tests : bypass total
|
||||
if _DASHBOARD_AUTH_DISABLED:
|
||||
return None
|
||||
if app.config.get("TESTING") and not app.config.get("DASHBOARD_AUTH_ENABLED"):
|
||||
return None
|
||||
|
||||
path = request.path or "/"
|
||||
if path in _PUBLIC_DASHBOARD_PATHS:
|
||||
return None
|
||||
|
||||
header_value = request.headers.get("Authorization", "")
|
||||
if _dashboard_auth_ok(header_value):
|
||||
return None
|
||||
|
||||
# Pas authentifié — challenge 401 avec WWW-Authenticate
|
||||
return Response(
|
||||
'{"error": "authentication required"}',
|
||||
status=401,
|
||||
mimetype="application/json",
|
||||
headers={"WWW-Authenticate": 'Basic realm="RPA Vision V3 Dashboard"'},
|
||||
)
|
||||
|
||||
|
||||
@app.get('/healthz')
|
||||
def healthz():
|
||||
"""Healthcheck minimal (systemd/k8s)."""
|
||||
"""Healthcheck minimal (systemd/k8s). Public — pas d'auth."""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'service': 'rpa-vision-v3-dashboard',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
|
||||
@app.get('/api/health')
|
||||
def api_health():
|
||||
"""Healthcheck JSON public — pas d'auth (monitoring externe)."""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'service': 'rpa-vision-v3-dashboard',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
|
||||
@app.get('/health')
|
||||
def health():
|
||||
"""Healthcheck public — pas d'auth (NPM reverse proxy)."""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'service': 'rpa-vision-v3-dashboard',
|
||||
@@ -532,21 +656,29 @@ def get_session(session_id):
|
||||
|
||||
@app.route('/api/agent/sessions/<session_id>/screenshot/<filename>')
|
||||
def get_screenshot(session_id, filename):
|
||||
"""Récupère un screenshot."""
|
||||
"""Récupère un screenshot.
|
||||
|
||||
Par défaut, si une version floutée (PII) `<stem>_blurred.png` existe à côté
|
||||
du fichier demandé, elle est servie à la place (affichage conforme RGPD).
|
||||
Pour obtenir la version brute, passer `?raw=1` (réservé aux endpoints
|
||||
d'entraînement/grounding, à protéger par auth).
|
||||
"""
|
||||
try:
|
||||
want_raw = request.args.get('raw', '0') in ('1', 'true', 'yes')
|
||||
|
||||
# Chercher le screenshot dans tous les répertoires
|
||||
screenshot_path = None
|
||||
|
||||
|
||||
for dir_path in SESSIONS_PATH.iterdir():
|
||||
if not dir_path.is_dir():
|
||||
continue
|
||||
|
||||
|
||||
# Chercher dans différents emplacements possibles
|
||||
possible_paths = [
|
||||
dir_path / "screenshots" / filename,
|
||||
dir_path / "shots" / filename,
|
||||
]
|
||||
|
||||
|
||||
# Chercher aussi dans les sous-répertoires
|
||||
for subdir in dir_path.iterdir():
|
||||
if subdir.is_dir():
|
||||
@@ -554,18 +686,26 @@ def get_screenshot(session_id, filename):
|
||||
subdir / "screenshots" / filename,
|
||||
subdir / "shots" / filename,
|
||||
])
|
||||
|
||||
|
||||
for path in possible_paths:
|
||||
if path.exists():
|
||||
screenshot_path = path
|
||||
break
|
||||
|
||||
|
||||
if screenshot_path:
|
||||
break
|
||||
|
||||
|
||||
if not screenshot_path:
|
||||
return jsonify({'error': 'Screenshot non trouvé'}), 404
|
||||
|
||||
|
||||
# Préférer la version floutée si dispo et si l'appelant ne demande pas le brut
|
||||
if not want_raw and "_blurred" not in screenshot_path.stem:
|
||||
blurred_candidate = screenshot_path.with_name(
|
||||
f"{screenshot_path.stem}_blurred{screenshot_path.suffix}"
|
||||
)
|
||||
if blurred_candidate.is_file():
|
||||
screenshot_path = blurred_candidate
|
||||
|
||||
return send_file(screenshot_path, mimetype='image/png')
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -1630,6 +1770,14 @@ SERVICES_CONFIG = {
|
||||
"url": "http://localhost:5005",
|
||||
"icon": "📡"
|
||||
},
|
||||
"session_cleaner": {
|
||||
"name": "Session Cleaner",
|
||||
"description": "Nettoyage de sessions avant replay (dépend du Streaming Server)",
|
||||
"port": 5006,
|
||||
"start_cmd": "cd {base} && {base}/.venv/bin/python3 tools/session_cleaner.py",
|
||||
"url": "http://localhost:5006",
|
||||
"icon": "🧹"
|
||||
},
|
||||
"web_dashboard": {
|
||||
"name": "Dashboard (ce service)",
|
||||
"description": "Panneau de contrôle RPA Vision V3",
|
||||
|
||||
Reference in New Issue
Block a user