diff --git a/agent_v0/agent_v1/ui/capture_server.py b/agent_v0/agent_v1/ui/capture_server.py index 05f7fdc08..de5e0cc17 100644 --- a/agent_v0/agent_v1/ui/capture_server.py +++ b/agent_v0/agent_v1/ui/capture_server.py @@ -3,15 +3,25 @@ Mini serveur HTTP sur l'agent Windows pour les captures d'ecran a la demande et les operations fichiers. Ecoute sur le port 5006 (configurable via RPA_CAPTURE_PORT). +Bind par defaut sur 127.0.0.1 (configurable via RPA_CAPTURE_BIND). Endpoints : GET /capture -> screenshot frais en base64 (JPEG) - GET /health -> {"status": "ok"} + GET /health -> {"status": "ok"} (pas d'auth — sonde liveness) POST /file-action -> operations fichiers (list, create, move, copy, sort) + +Securite : + - Authentification Bearer obligatoire (RPA_API_TOKEN) pour /capture et + /file-action. Sans token configure, ces endpoints sont desactives. + - Les tentatives non authentifiees sont loguees (WARNING) avec l'IP source. + - Bind defaut localhost. Pour exposer sur le LAN (cas VWB backend qui + appelle l'agent a distance), definir explicitement + RPA_CAPTURE_BIND=0.0.0.0. L'auth reste alors la seule protection. """ import threading import logging import json import base64 +import hmac import io import os import time @@ -20,6 +30,17 @@ from http.server import HTTPServer, BaseHTTPRequestHandler logger = logging.getLogger(__name__) CAPTURE_PORT = int(os.environ.get("RPA_CAPTURE_PORT", "5006")) +# Bind par defaut sur localhost — defense en profondeur. +# Pour le deploiement VWB (backend Linux -> agent Windows), definir +# RPA_CAPTURE_BIND=0.0.0.0 explicitement. L'auth par token reste requise. +CAPTURE_BIND = os.environ.get("RPA_CAPTURE_BIND", "127.0.0.1") + +# Token d'authentification (partage avec le streaming). Doit etre defini pour +# que /capture et /file-action soient accessibles. +CAPTURE_TOKEN = os.environ.get("RPA_API_TOKEN", "") + +# Endpoints ouverts (pas d'auth requise — sondes techniques uniquement) +_PUBLIC_PATHS = {"/health"} # Floutage des données sensibles (conformité AI Act) BLUR_SENSITIVE = os.environ.get("RPA_BLUR_SENSITIVE", "true").lower() in ("true", "1", "yes") @@ -33,6 +54,8 @@ class CaptureHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/capture": + if not self._check_auth(): + return self._handle_capture() elif self.path == "/health": self._send_json(200, {"status": "ok"}) @@ -41,10 +64,56 @@ class CaptureHandler(BaseHTTPRequestHandler): def do_POST(self): if self.path == "/file-action": + if not self._check_auth(): + return self._handle_file_action() else: self._send_json(404, {"error": "not found"}) + # ------------------------------------------------------------------ + + def _check_auth(self) -> bool: + """Valide le Bearer token. Renvoie 401/503 si invalide. + + - Si aucun token n'est configure cote serveur (RPA_API_TOKEN vide), + on refuse toutes les requetes sensibles (503) — fail-closed. + - Sinon, on compare en temps constant via hmac.compare_digest. + - Les tentatives echouees sont loguees avec l'IP source. + """ + # Autoriser les endpoints publics + if self.path in _PUBLIC_PATHS: + return True + + peer = self.client_address[0] if self.client_address else "?" + + if not CAPTURE_TOKEN: + logger.error( + "Refus %s depuis %s : RPA_API_TOKEN non configure " + "(capture server en mode fail-closed)", + self.path, peer, + ) + self._send_json(503, { + "error": "capture server non configure (token manquant)", + }) + return False + + auth_header = self.headers.get("Authorization", "") + token = "" + if auth_header.startswith("Bearer "): + token = auth_header[len("Bearer "):].strip() + + if not token or not hmac.compare_digest(token, CAPTURE_TOKEN): + logger.warning( + "Tentative d'acces non autorisee a %s depuis %s " + "(token %s)", + self.path, peer, + "absent" if not token else "invalide", + ) + self._send_json(401, {"error": "unauthorized"}) + return False + + return True + def do_OPTIONS(self): """Gestion CORS preflight.""" self.send_response(200) @@ -351,21 +420,46 @@ class _FileActionHandlerLocal: class CaptureServer: """Serveur de capture d'ecran en temps reel (thread daemon).""" - def __init__(self, port: int = CAPTURE_PORT): + def __init__(self, port: int = CAPTURE_PORT, bind: str = CAPTURE_BIND): self._port = port + self._bind = bind self._server: HTTPServer | None = None self._thread: threading.Thread | None = None def start(self): - """Demarre le serveur dans un thread daemon.""" + """Demarre le serveur dans un thread daemon. + + Avertit si le serveur est expose sur le LAN sans token configure. + """ + # Defense en profondeur : refus de demarrer si expose LAN sans auth + exposed_lan = self._bind not in ("127.0.0.1", "localhost", "::1") + if exposed_lan and not CAPTURE_TOKEN: + logger.error( + "REFUS demarrage capture server : bind=%s (LAN) sans " + "RPA_API_TOKEN. Definir le token ou RPA_CAPTURE_BIND=127.0.0.1.", + self._bind, + ) + print( + f"[CAPTURE] REFUS demarrage : bind={self._bind} sans token. " + f"Definir RPA_API_TOKEN ou RPA_CAPTURE_BIND=127.0.0.1." + ) + return + try: - self._server = HTTPServer(("0.0.0.0", self._port), CaptureHandler) + self._server = HTTPServer((self._bind, self._port), CaptureHandler) self._thread = threading.Thread( target=self._server.serve_forever, daemon=True ) self._thread.start() - logger.info(f"Capture server demarre sur le port {self._port}") - print(f"[CAPTURE] Serveur de capture demarre sur le port {self._port}") + auth_mode = "token requis" if CAPTURE_TOKEN else "token absent (fail-closed)" + logger.info( + "Capture server demarre sur %s:%s (%s)", + self._bind, self._port, auth_mode, + ) + print( + f"[CAPTURE] Serveur de capture demarre sur " + f"{self._bind}:{self._port} ({auth_mode})" + ) except Exception as e: logger.error(f"Impossible de demarrer le capture server : {e}") print(f"[CAPTURE] ERREUR demarrage : {e}") diff --git a/visual_workflow_builder/backend/api/screen_capture.py b/visual_workflow_builder/backend/api/screen_capture.py index 72b50d661..09059697f 100644 --- a/visual_workflow_builder/backend/api/screen_capture.py +++ b/visual_workflow_builder/backend/api/screen_capture.py @@ -136,8 +136,18 @@ def capture_windows(): agent_port = int(os.environ.get('RPA_WINDOWS_AGENT_PORT', '5006')) agent_url = f'http://{agent_host}:{agent_port}/capture' + # Auth : l'agent exige un Bearer token (meme RPA_API_TOKEN que le streaming) + api_token = os.environ.get('RPA_API_TOKEN', '') + headers = {'Authorization': f'Bearer {api_token}'} if api_token else {} + try: - resp = http_client.get(agent_url, timeout=10) + resp = http_client.get(agent_url, headers=headers, timeout=10) + if resp.status_code == 401: + return jsonify({ + 'error': 'Agent Windows : authentification refusee', + 'hint': 'Verifiez que RPA_API_TOKEN est defini et identique ' + 'cote backend VWB et cote agent Windows.', + }), 401 if resp.ok: return jsonify(resp.json()) return jsonify({ diff --git a/visual_workflow_builder/backend/api_v3/dag_execute.py b/visual_workflow_builder/backend/api_v3/dag_execute.py index 895b6d83f..e98ffb41d 100644 --- a/visual_workflow_builder/backend/api_v3/dag_execute.py +++ b/visual_workflow_builder/backend/api_v3/dag_execute.py @@ -359,7 +359,7 @@ def _execute_db_foreach( # 3. Pour chaque ligne, injecter et exécuter iteration_results = [] - model = executor_kwargs.get("model", "qwen3-vl:8b") + model = executor_kwargs.get("model", os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b"))) ollama_endpoint = executor_kwargs.get("ollama_endpoint", "http://localhost:11434") timeout = executor_kwargs.get("timeout", 300) @@ -514,7 +514,7 @@ def execute_dag(workflow_id: str): # Paramètres optionnels timeout = data.get("timeout", 300) - model = data.get("model", "qwen3-vl:8b") + model = data.get("model", os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b"))) ollama_endpoint = data.get("ollama_endpoint", "http://localhost:11434") executor_kwargs = { @@ -1000,21 +1000,34 @@ def execute_windows(): file_actions = [a for a in data['actions'] if a.get('type', '') in _FILE_ACTION_TYPES] if file_actions: # Exécuter les actions fichiers via l'agent Windows + # Auth : Bearer token obligatoire (capture_server.py exige RPA_API_TOKEN) + _agent_host = os.environ.get('RPA_WINDOWS_AGENT_HOST', '192.168.1.11') + _agent_port = int(os.environ.get('RPA_WINDOWS_AGENT_PORT', '5006')) + _agent_url = f'http://{_agent_host}:{_agent_port}/file-action' + _api_token = os.environ.get('RPA_API_TOKEN', '') + _file_headers = {'Authorization': f'Bearer {_api_token}'} if _api_token else {} + file_results = [] for fa in file_actions: try: fa_resp = req.post( - 'http://192.168.1.11:5006/file-action', + _agent_url, json={ 'action': fa['type'], 'params': fa.get('parameters', {}), }, + headers=_file_headers, timeout=30, ) - file_results.append(fa_resp.json()) + if fa_resp.status_code == 401: + file_results.append({ + 'error': "Agent Windows : auth refusee (verifier RPA_API_TOKEN)", + }) + else: + file_results.append(fa_resp.json()) except req.ConnectionError: file_results.append({ - 'error': "Agent Windows (port 5006) non disponible pour l'action fichier" + 'error': f"Agent Windows ({_agent_host}:{_agent_port}) non disponible pour l'action fichier" }) except Exception as e: file_results.append({'error': str(e)})