""" Cliente HTTP para PANEL_BASES_ANEXO24. Cada base de datos está asignada en el PANEL a un servidor de restauración (registrado en restore_targets). CloudRestoreAS, tras resolver el db_name del archivo, pregunta al PANEL (polling) qué servidor corresponde a ESA base y restaura ahí; al terminar, reporta el resultado del job. Política ante fallo (G8/G9): si el PANEL no responde o la base no tiene servidor asignado, get_target_for_database devuelve None y el worker deja el job en cola para reintentar; nunca se restaura con datos inciertos. """ import os from typing import Optional from urllib.parse import urlparse, quote import requests from ..utils.logger import app_logger # Timeout (connect, read) en segundos para las llamadas al PANEL. DEFAULT_TIMEOUT = (5, 10) def _http_verify(verify_ssl: Optional[bool] = None) -> bool: """Verificación TLS para requests. Por defecto False (panel dev con cert propio).""" if verify_ssl is not None: return verify_ssl env = os.getenv("CLOUDRESTORE_PANEL_VERIFY_SSL", "").strip().lower() if env in ("1", "true", "yes"): return True if env in ("0", "false", "no"): return False return False # Campos obligatorios del servidor devuelto por el endpoint (modo hub central + SFTP). REQUIRED_TARGET_FIELDS = ( "server", "username", "password", "data_folder", "ssh_host", "ssh_username", "ssh_password", "remote_inbox_path", ) # Modo colocado (CRA en el mismo servidor): solo credenciales SQL locales. REQUIRED_COLOCATED_TARGET_FIELDS = ( "name", "server", "username", "password", "data_folder", ) # Reenvío de ZIP por SFTP al input_folder del destino. REQUIRED_FORWARD_TARGET_FIELDS = ( "name", "ssh_host", "ssh_username", "ssh_password", "input_folder", ) def _normalize_base_url(api_url: str) -> Optional[str]: """Valida y normaliza la URL base del PANEL. Devuelve None si es inválida.""" if not api_url or not api_url.strip(): return None base = api_url.strip().rstrip("/") parsed = urlparse(base) # Solo http/https; evita esquemas peligrosos (file://, etc.) if parsed.scheme not in ("http", "https") or not parsed.netloc: app_logger.error(f"URL de PANEL inválida (esquema/host): {api_url}") return None return base def _validate_target(data: dict, *, colocated: bool = False) -> bool: """Verifica que el servidor devuelto tenga todos los campos requeridos no vacíos.""" fields = REQUIRED_COLOCATED_TARGET_FIELDS if colocated else REQUIRED_TARGET_FIELDS for field in fields: value = data.get(field) if value is None or (isinstance(value, str) and not value.strip()): app_logger.error(f"Servidor del PANEL sin campo requerido: '{field}'") return False return True def get_target_for_database( api_url: str, api_token: str, db_name: str, instance_key: Optional[str] = None, ) -> Optional[dict]: """ Consulta GET /api/restore/target-for?database=: el servidor de restauración asignado a esa base de datos. Returns: dict con id, name, server, username, password, data_folder, ssh_host, ssh_port, ssh_username, ssh_password, remote_inbox_path si la base tiene servidor asignado; None si no tiene asignación, el PANEL no responde o la config es inválida. """ base = _normalize_base_url(api_url) if base is None: return None if not api_token or not api_token.strip(): app_logger.error("Token de PANEL no configurado; no se puede consultar el servidor de la base") return None if not db_name or not db_name.strip(): app_logger.error("db_name vacío; no se puede consultar el servidor de la base") return None colocated = bool(instance_key and instance_key.strip()) url = f"{base}/api/restore/target-for?database={quote(db_name.strip())}" if colocated: url += f"&instance={quote(instance_key.strip())}" headers = {"Authorization": f"Bearer {api_token.strip()}"} try: resp = requests.get( url, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify() ) except requests.exceptions.RequestException as e: app_logger.error(f"PANEL no accesible al consultar servidor de la base '{db_name}': {e}") return None if resp.status_code == 404: app_logger.warning(f"La base '{db_name}' no tiene servidor de restauración asignado en el PANEL") return None if resp.status_code == 401: app_logger.error("PANEL rechazó el token de CloudRestoreAS (401)") return None if resp.status_code != 200: app_logger.error(f"PANEL respondió {resp.status_code} al consultar servidor de la base") return None try: data = resp.json() except ValueError: app_logger.error("Respuesta del PANEL no es JSON válido") return None if not _validate_target(data, colocated=colocated): return None name = data.get("name") or data.get("server") app_logger.info(f"Servidor para la base '{db_name}': {name} ({data.get('server')})") return data def list_restore_target_names(api_url: str, api_token: str) -> list[str]: """ Consulta GET /api/restore/target-catalog: nombres de servidores de restauración registrados en el panel (para llenar el selector de instancia). Returns: Lista de nombres; lista vacía si falla la conexión, el token es inválido o la respuesta no es válida. """ base = _normalize_base_url(api_url) if base is None: return [] if not api_token or not api_token.strip(): app_logger.error("Token de PANEL no configurado; no se puede consultar el catálogo") return [] url = f"{base}/api/restore/target-catalog" headers = {"Authorization": f"Bearer {api_token.strip()}"} try: resp = requests.get( url, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify() ) except requests.exceptions.RequestException as e: app_logger.error(f"PANEL no accesible al consultar catálogo de servidores: {e}") return [] if resp.status_code == 401: app_logger.error("PANEL rechazó el token de CloudRestoreAS (401) al consultar catálogo") return [] if resp.status_code != 200: app_logger.error(f"PANEL respondió {resp.status_code} al consultar catálogo de servidores") return [] try: data = resp.json() except ValueError: app_logger.error("Respuesta del PANEL (target-catalog) no es JSON válido") return [] targets = data.get("targets") if not isinstance(targets, list): app_logger.error("Respuesta del PANEL (target-catalog) sin lista 'targets'") return [] names: list[str] = [] for item in targets: if isinstance(item, dict): name = item.get("name") if isinstance(name, str) and name.strip(): names.append(name.strip()) return names def resolve_route( api_url: str, api_token: str, filename: str, instance_key: Optional[str] = None, ) -> Optional[dict]: """ Consulta GET /api/restore/resolve-route: resuelve nodo/destino desde el nombre del archivo y devuelve action (restore_local | forward) más target completo. Returns: dict con action, db_name, node_key, target; None si el panel no responde, token inválido o la respuesta no cumple el contrato. """ base = _normalize_base_url(api_url) if base is None: return None if not api_token or not api_token.strip(): app_logger.error("Token de PANEL no configurado; no se puede resolver ruta") return None if not filename or not filename.strip(): app_logger.error("filename vacío; no se puede resolver ruta") return None url = f"{base}/api/restore/resolve-route?filename={quote(filename.strip())}" key = (instance_key or "").strip() if key: url += f"&instance={quote(key)}" headers = {"Authorization": f"Bearer {api_token.strip()}"} try: resp = requests.get( url, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify() ) except requests.exceptions.RequestException as e: app_logger.error(f"PANEL no accesible al resolver ruta para '{filename}': {e}") return None if resp.status_code == 404: app_logger.warning(f"El archivo '{filename}' no tiene nodo/base asignado en el PANEL") return None if resp.status_code == 503: app_logger.warning( f"Destino aún sin carpeta de entrada reportada para '{filename}'" ) return None if resp.status_code == 401: app_logger.error("PANEL rechazó el token de CloudRestoreAS (401) al resolver ruta") return None if resp.status_code != 200: app_logger.error(f"PANEL respondió {resp.status_code} al resolver ruta") return None try: data = resp.json() except ValueError: app_logger.error("Respuesta del PANEL (resolve-route) no es JSON válido") return None action = data.get("action") target = data.get("target") db_name = data.get("db_name") if action not in ("restore_local", "forward") or not isinstance(target, dict): app_logger.error("Respuesta del PANEL (resolve-route) con action o target inválidos") return None if not isinstance(db_name, str) or not db_name.strip(): app_logger.error("Respuesta del PANEL (resolve-route) sin db_name válido") return None if action == "restore_local": if not _validate_target(target, colocated=True): return None elif not _validate_target_fields(target, REQUIRED_FORWARD_TARGET_FIELDS): return None app_logger.info( f"Ruta para '{filename}': action={action}, destino={target.get('name')}, db={db_name}" ) return data def _validate_target_fields(data: dict, fields: tuple) -> bool: """Verifica campos requeridos no vacíos (lista explícita).""" for field in fields: value = data.get(field) if value is None or (isinstance(value, str) and not value.strip()): app_logger.error(f"Servidor del PANEL sin campo requerido para forward: '{field}'") return False return True def report_instance_config( api_url: str, api_token: str, input_folder: str, host_name: Optional[str] = None, app_version: Optional[str] = None, instance_key: Optional[str] = None, processed_folder: Optional[str] = None, platform_name: Optional[str] = None, arch: Optional[str] = None, install_path: Optional[str] = None, ) -> bool: """ Reporta la carpeta de entrada vigente a POST /api/restore/instance-config (best-effort). El panel solo la muestra; CloudRestoreAS es la única fuente de escritura. platform_name y arch identifican el build de ESTA instalación ("windows"/"linux", "x86_64"/"arm64"). El PANEL los usa para saber qué artefacto le toca a este servidor cuando instala o actualiza; sin ellos cae al texto libre de restore_targets.os. install_path es la carpeta donde vive el ejecutable (APP_DIR). Es un espacio de rutas DISTINTO de input_folder: ahí abajo está config/.env con las rutas de trabajo que el operador haya personalizado. El PANEL la necesita para actualizar en el lugar correcto en vez de crear una segunda instalación con la configuración por omisión. Returns: True si el PANEL aceptó el reporte (200), False en cualquier otro caso. """ base = _normalize_base_url(api_url) if base is None or not api_token or not api_token.strip(): return False if not input_folder or not input_folder.strip(): app_logger.warning("input_folder vacío; no se reporta al PANEL") return False url = f"{base}/api/restore/instance-config" headers = {"Authorization": f"Bearer {api_token.strip()}"} key = (instance_key or "").strip() or None payload = { "input_folder": input_folder.strip(), "processed_folder": (processed_folder or "").strip() or None, "host_name": (host_name or "").strip() or None, "app_version": (app_version or "").strip() or None, "platform": (platform_name or "").strip() or None, "arch": (arch or "").strip() or None, "install_path": (install_path or "").strip() or None, } if key: payload["instance_key"] = key try: resp = requests.post( url, json=payload, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify() ) except requests.exceptions.RequestException as e: app_logger.error(f"No se pudo reportar la carpeta de entrada al PANEL: {e}") return False if resp.status_code == 200: app_logger.info(f"Carpeta de entrada reportada al PANEL: {input_folder.strip()}") return True app_logger.error( f"PANEL respondió {resp.status_code} al reportar la carpeta de entrada" ) return False def report_job_result( api_url: str, api_token: str, filename: str, status: str, restore_target_id: Optional[int] = None, db_name: Optional[str] = None, duration_ms: Optional[int] = None, error_message: Optional[str] = None, ) -> bool: """ Reporta el resultado de un job a POST /api/restore/job-result (best-effort). No lanza excepciones: un fallo aquí no debe romper el flujo de restauración. Returns: True si el PANEL aceptó el reporte (201), False en cualquier otro caso. """ base = _normalize_base_url(api_url) if base is None or not api_token or not api_token.strip(): return False url = f"{base}/api/restore/job-result" headers = {"Authorization": f"Bearer {api_token.strip()}"} payload = { "filename": filename, "restore_target_id": restore_target_id, "db_name": db_name, "status": status, "duration_ms": duration_ms, "error_message": error_message, } try: resp = requests.post( url, json=payload, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify() ) except requests.exceptions.RequestException as e: app_logger.error(f"No se pudo reportar el resultado del job al PANEL: {e}") return False if resp.status_code == 201: return True app_logger.error(f"PANEL respondió {resp.status_code} al reportar el resultado del job") return False def test_connection(api_url: str, api_token: str) -> tuple[bool, Optional[str]]: """ Prueba conectividad y autenticación contra el PANEL para la UI de configuración. Consulta /target-for con un nombre de base inexistente: un 404 confirma que el PANEL responde y el token es válido (la base simplemente no existe). Returns: (True, mensaje) si el PANEL responde y el token es aceptado; (False, "mensaje de error") si no se pudo conectar o el token fue rechazado. """ base = _normalize_base_url(api_url) if base is None: return False, "URL de PANEL inválida (debe iniciar con http:// o https://)" if not api_token or not api_token.strip(): return False, "Falta el token de API del PANEL" url = f"{base}/api/restore/target-for?database={quote('__cloudrestore_ping__')}" headers = {"Authorization": f"Bearer {api_token.strip()}"} try: resp = requests.get( url, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify() ) except requests.exceptions.RequestException as e: return False, f"No se pudo conectar al PANEL: {e}" # 200 (base de ping existiera) o 404 (no existe) → conexión y token OK. if resp.status_code in (200, 404): return True, "Conexión y token correctos" if resp.status_code == 401: return False, "Token rechazado por el PANEL (401)" return False, f"El PANEL respondió con código {resp.status_code}"