- Token obligatoire (RPA_API_TOKEN) sur /capture et /file-action - Bind 127.0.0.1 par défaut, 0.0.0.0 exige token (fail-closed) - /health reste public pour monitoring - VWB backend injecte le Bearer pour les proxys distants - hmac.compare_digest pour comparaison temps constant Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
472 lines
17 KiB
Python
472 lines
17 KiB
Python
"""
|
|
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"} (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
|
|
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")
|
|
|
|
|
|
class CaptureHandler(BaseHTTPRequestHandler):
|
|
"""Retourne un screenshot frais a chaque requete GET /capture.
|
|
|
|
Gere aussi les actions fichiers via POST /file-action.
|
|
"""
|
|
|
|
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"})
|
|
else:
|
|
self._send_json(404, {"error": "not found"})
|
|
|
|
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)
|
|
self._cors_headers()
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
def _handle_file_action(self):
|
|
"""Execute une action fichier sur la machine Windows locale.
|
|
|
|
Body JSON attendu :
|
|
{"action": "file_sort_by_ext", "params": {"source_dir": "C:\\..."}}
|
|
"""
|
|
try:
|
|
content_length = int(self.headers.get("Content-Length", 0))
|
|
body = self.rfile.read(content_length)
|
|
data = json.loads(body.decode("utf-8"))
|
|
|
|
action = data.get("action", "")
|
|
params = data.get("params", {})
|
|
|
|
if not action:
|
|
self._send_json(400, {"error": "Parametre 'action' requis"})
|
|
return
|
|
|
|
handler = _FileActionHandlerLocal()
|
|
result = handler.execute(action, params)
|
|
code = 500 if "error" in result else 200
|
|
self._send_json(code, result)
|
|
|
|
except json.JSONDecodeError:
|
|
self._send_json(400, {"error": "JSON invalide"})
|
|
except Exception as e:
|
|
logger.error(f"Erreur file-action : {e}")
|
|
self._send_json(500, {"error": str(e)})
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
def _handle_capture(self):
|
|
"""Capture l'ecran principal et le renvoie en base64 JPEG."""
|
|
t0 = time.perf_counter()
|
|
try:
|
|
import mss
|
|
from PIL import Image
|
|
|
|
with mss.mss() as sct:
|
|
monitor = sct.monitors[1] # ecran principal
|
|
raw = sct.grab(monitor)
|
|
|
|
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
|
|
|
|
# Floutage des données sensibles (conformité AI Act)
|
|
if BLUR_SENSITIVE:
|
|
try:
|
|
from ..vision.blur_sensitive import blur_sensitive_regions
|
|
blur_sensitive_regions(img)
|
|
except ImportError:
|
|
logger.warning("Module blur_sensitive non disponible")
|
|
|
|
buf = io.BytesIO()
|
|
img.save(buf, format="JPEG", quality=80)
|
|
img_b64 = base64.b64encode(buf.getvalue()).decode()
|
|
|
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
logger.info(f"Capture {img.width}x{img.height} en {elapsed_ms:.0f}ms")
|
|
|
|
self._send_json(200, {
|
|
"image": img_b64,
|
|
"width": img.width,
|
|
"height": img.height,
|
|
"format": "jpeg",
|
|
"source": "windows_live",
|
|
"capture_ms": round(elapsed_ms),
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur capture : {e}")
|
|
self._send_json(500, {"error": str(e)})
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
def _send_json(self, code: int, data: dict):
|
|
body = json.dumps(data).encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self._cors_headers()
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _cors_headers(self):
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
|
|
def log_message(self, format, *args):
|
|
"""Supprime les logs HTTP par defaut (trop verbeux)."""
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gestionnaire d'actions fichiers local (execute sur la machine Windows)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Repertoires autorises sur Windows (securite anti-traversal)
|
|
_WIN_ALLOWED_ROOTS = [
|
|
"C:\\Users",
|
|
"D:\\",
|
|
"E:\\",
|
|
]
|
|
|
|
|
|
def _normalize_win_path(path_str: str) -> str:
|
|
"""Normalise un chemin Windows."""
|
|
import ntpath
|
|
return ntpath.normpath(path_str)
|
|
|
|
|
|
def _is_safe_win_path(path_str: str) -> bool:
|
|
"""Verifie qu'un chemin Windows est dans une zone autorisee."""
|
|
if not path_str or not path_str.strip():
|
|
return False
|
|
norm = _normalize_win_path(path_str).upper()
|
|
return any(norm.startswith(root.upper()) for root in _WIN_ALLOWED_ROOTS)
|
|
|
|
|
|
class _FileActionHandlerLocal:
|
|
"""Execute les operations fichiers sur la machine locale (Windows)."""
|
|
|
|
def execute(self, action_type: str, params: dict) -> dict:
|
|
"""Dispatch vers la bonne methode selon le type d'action."""
|
|
handlers = {
|
|
"file_list_dir": self._list_dir,
|
|
"file_create_dir": self._create_dir,
|
|
"file_move": self._move_file,
|
|
"file_copy": self._copy_file,
|
|
"file_sort_by_ext": self._sort_by_extension,
|
|
}
|
|
handler = handlers.get(action_type)
|
|
if not handler:
|
|
return {"error": f"Action fichier inconnue : {action_type}"}
|
|
try:
|
|
return handler(params)
|
|
except Exception as e:
|
|
logger.error(f"Erreur action fichier '{action_type}' : {e}")
|
|
return {"error": str(e)}
|
|
|
|
def _list_dir(self, params: dict) -> dict:
|
|
"""Liste les fichiers d'un dossier."""
|
|
import fnmatch as _fnmatch
|
|
from pathlib import Path as _Path
|
|
|
|
path_str = params.get("path", "")
|
|
pattern = params.get("pattern", "*")
|
|
if not path_str:
|
|
return {"error": "Parametre 'path' requis"}
|
|
if not _is_safe_win_path(path_str):
|
|
return {"error": f"Chemin non autorise : {path_str}"}
|
|
|
|
source = _Path(path_str)
|
|
if not source.exists():
|
|
return {"error": f"Dossier introuvable : {path_str}"}
|
|
if not source.is_dir():
|
|
return {"error": f"Pas un dossier : {path_str}"}
|
|
|
|
files = []
|
|
extensions = {}
|
|
for item in source.iterdir():
|
|
if item.is_file() and _fnmatch.fnmatch(item.name, pattern):
|
|
ext = item.suffix.lstrip(".").lower() or "sans_extension"
|
|
files.append({
|
|
"name": item.name,
|
|
"extension": ext,
|
|
"size": item.stat().st_size,
|
|
"path": str(item),
|
|
})
|
|
extensions[ext] = extensions.get(ext, 0) + 1
|
|
|
|
logger.info(f"Liste dossier '{path_str}' : {len(files)} fichiers")
|
|
return {"files": files, "count": len(files), "extensions": extensions, "path": path_str}
|
|
|
|
def _create_dir(self, params: dict) -> dict:
|
|
"""Cree un dossier (parents inclus)."""
|
|
from pathlib import Path as _Path
|
|
|
|
path_str = params.get("path", "")
|
|
if not path_str:
|
|
return {"error": "Parametre 'path' requis"}
|
|
if not _is_safe_win_path(path_str):
|
|
return {"error": f"Chemin non autorise : {path_str}"}
|
|
|
|
target = _Path(path_str)
|
|
existed = target.exists()
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
logger.info(f"Dossier '{path_str}' {'existait deja' if existed else 'cree'}")
|
|
return {"created": not existed, "path": path_str, "already_existed": existed}
|
|
|
|
def _move_file(self, params: dict) -> dict:
|
|
"""Deplace ou renomme un fichier."""
|
|
import shutil as _shutil
|
|
from pathlib import Path as _Path
|
|
|
|
src = params.get("source", "")
|
|
dst = params.get("destination", "")
|
|
if not src or not dst:
|
|
return {"error": "Parametres 'source' et 'destination' requis"}
|
|
if not _is_safe_win_path(src):
|
|
return {"error": f"Source non autorisee : {src}"}
|
|
if not _is_safe_win_path(dst):
|
|
return {"error": f"Destination non autorisee : {dst}"}
|
|
|
|
if not _Path(src).exists():
|
|
return {"error": f"Fichier source introuvable : {src}"}
|
|
|
|
_Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
|
_shutil.move(src, dst)
|
|
logger.info(f"Fichier deplace : '{src}' -> '{dst}'")
|
|
return {"moved": True, "source": src, "destination": dst}
|
|
|
|
def _copy_file(self, params: dict) -> dict:
|
|
"""Copie un fichier."""
|
|
import shutil as _shutil
|
|
from pathlib import Path as _Path
|
|
|
|
src = params.get("source", "")
|
|
dst = params.get("destination", "")
|
|
if not src or not dst:
|
|
return {"error": "Parametres 'source' et 'destination' requis"}
|
|
if not _is_safe_win_path(src):
|
|
return {"error": f"Source non autorisee : {src}"}
|
|
if not _is_safe_win_path(dst):
|
|
return {"error": f"Destination non autorisee : {dst}"}
|
|
|
|
source = _Path(src)
|
|
if not source.exists():
|
|
return {"error": f"Fichier source introuvable : {src}"}
|
|
|
|
_Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
|
if source.is_dir():
|
|
_shutil.copytree(src, dst)
|
|
else:
|
|
_shutil.copy2(src, dst)
|
|
logger.info(f"Fichier copie : '{src}' -> '{dst}'")
|
|
return {"copied": True, "source": src, "destination": dst}
|
|
|
|
def _sort_by_extension(self, params: dict) -> dict:
|
|
"""Classe les fichiers par extension dans des sous-dossiers."""
|
|
import shutil as _shutil
|
|
from pathlib import Path as _Path
|
|
|
|
source_dir_str = params.get("source_dir", "")
|
|
create_subdirs = params.get("create_subdirs", True)
|
|
|
|
if not source_dir_str:
|
|
return {"error": "Parametre 'source_dir' requis"}
|
|
if not _is_safe_win_path(source_dir_str):
|
|
return {"error": f"Chemin non autorise : {source_dir_str}"}
|
|
|
|
source = _Path(source_dir_str)
|
|
if not source.exists():
|
|
return {"error": f"Dossier introuvable : {source_dir_str}"}
|
|
if not source.is_dir():
|
|
return {"error": f"Pas un dossier : {source_dir_str}"}
|
|
|
|
moved = []
|
|
extensions = {}
|
|
|
|
for f in source.iterdir():
|
|
if f.is_file():
|
|
ext = f.suffix.lstrip(".").lower() or "sans_extension"
|
|
target_dir = source / ext
|
|
|
|
if create_subdirs:
|
|
target_dir.mkdir(exist_ok=True)
|
|
elif not target_dir.exists():
|
|
continue
|
|
|
|
dest = target_dir / f.name
|
|
# Eviter ecrasement
|
|
if dest.exists():
|
|
base = f.stem
|
|
counter = 1
|
|
while dest.exists():
|
|
dest = target_dir / f"{base}_{counter}{f.suffix}"
|
|
counter += 1
|
|
|
|
_shutil.move(str(f), str(dest))
|
|
moved.append({"file": f.name, "to": ext, "destination": str(dest)})
|
|
extensions[ext] = extensions.get(ext, 0) + 1
|
|
|
|
logger.info(
|
|
f"Classement par extension '{source_dir_str}' : {len(moved)} fichiers"
|
|
)
|
|
return {
|
|
"moved": moved,
|
|
"count": len(moved),
|
|
"extensions": extensions,
|
|
"source_dir": source_dir_str,
|
|
}
|
|
|
|
|
|
class CaptureServer:
|
|
"""Serveur de capture d'ecran en temps reel (thread daemon)."""
|
|
|
|
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.
|
|
|
|
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((self._bind, self._port), CaptureHandler)
|
|
self._thread = threading.Thread(
|
|
target=self._server.serve_forever, daemon=True
|
|
)
|
|
self._thread.start()
|
|
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}")
|
|
|
|
def stop(self):
|
|
"""Arrete le serveur proprement."""
|
|
if self._server:
|
|
self._server.shutdown()
|
|
logger.info("Capture server arrete")
|