""" Transferencia del .bak al servidor SQL externo vía SFTP/SSH (paramiko). Los servidores SQL destino son máquinas externas independientes (no comparten red local con CloudRestoreAS), por lo que el .bak se sube por SFTP al servidor y el SQL Server restaura desde su disco local (remote_inbox_path). Tras el job, el .bak del servidor se elimina siempre (G10). Nota de seguridad: se usa AutoAddPolicy para las host keys (TOFU). Para endurecer en producción conviene fijar/known_hosts las claves de cada servidor. """ import ntpath import posixpath from pathlib import Path from typing import Optional import paramiko from ..utils.logger import app_logger # Timeout de conexión SSH en segundos. SSH_TIMEOUT = 30 class SFTPCopyError(Exception): """Error al transferir o limpiar el .bak en el servidor remoto vía SFTP.""" def _sftp_path(remote_inbox_path: str, filename: str) -> str: """ Ruta estilo POSIX para SFTP a partir de la carpeta destino (que puede venir en formato Windows, p. ej. C:\\RestoreInbox). OpenSSH en Windows acepta 'C:/...'. """ posix_dir = remote_inbox_path.replace("\\", "/").rstrip("/") return f"{posix_dir}/{filename}" def windows_restore_path(remote_inbox_path: str, filename: str) -> str: """Ruta Windows que usará RESTORE DATABASE en el servidor (C:\\RestoreInbox\\x.bak).""" return ntpath.join(remote_inbox_path, filename) def _connect(cfg: dict) -> paramiko.SSHClient: """Abre una conexión SSH con las credenciales del servidor (cfg del PANEL).""" client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: client.connect( hostname=cfg["ssh_host"], port=int(cfg.get("ssh_port") or 22), username=cfg["ssh_username"], password=cfg["ssh_password"], timeout=SSH_TIMEOUT, allow_agent=False, look_for_keys=False, ) except Exception as e: raise SFTPCopyError( f"No se pudo conectar por SSH a {cfg.get('ssh_host')}:{cfg.get('ssh_port')}: {e}" ) from e return client def upload_to_remote(local_bak: str, cfg: dict) -> str: """ Sube `local_bak` al servidor remoto vía SFTP, a remote_inbox_path/filename. Args: local_bak: ruta local del .bak ya extraído. cfg: dict con ssh_host, ssh_port, ssh_username, ssh_password, remote_inbox_path. Returns: Ruta SFTP (POSIX) del .bak en el servidor, para limpieza posterior. Raises: SFTPCopyError: si el origen no existe o falla la conexión/transferencia. """ remote_folder = cfg.get("remote_inbox_path") or "" return upload_file_to_folder(local_bak, cfg, remote_folder) def upload_file_to_folder(local_file: str, cfg: dict, remote_folder: str) -> str: """ Sube un archivo local a remote_folder/filename vía SFTP. Args: local_file: ruta local del archivo. cfg: dict con ssh_host, ssh_port, ssh_username, ssh_password. remote_folder: carpeta destino en el servidor remoto (Windows o POSIX). Returns: Ruta SFTP (POSIX) del archivo en el servidor. Raises: SFTPCopyError: si el origen no existe o falla la transferencia. """ src = Path(local_file) if not src.is_file(): raise SFTPCopyError(f"El archivo local no existe: {local_file}") if not remote_folder or not str(remote_folder).strip(): raise SFTPCopyError("La carpeta remota destino está vacía") remote_sftp = _sftp_path(str(remote_folder).strip(), src.name) client = _connect(cfg) try: sftp = client.open_sftp() try: app_logger.info(f"Subiendo archivo por SFTP a {cfg['ssh_host']}: {remote_sftp}") sftp.put(str(src), remote_sftp) finally: sftp.close() except SFTPCopyError: raise except Exception as e: raise SFTPCopyError(f"Fallo al subir archivo por SFTP ({remote_sftp}): {e}") from e finally: client.close() return remote_sftp def upload_zip_parts(local_paths: list[str], cfg: dict, remote_folder: str) -> list[str]: """ Sube uno o más archivos ZIP (incl. multipart) al input_folder del destino. Returns: Lista de rutas SFTP subidas. """ uploaded: list[str] = [] for local_path in local_paths: uploaded.append(upload_file_to_folder(local_path, cfg, remote_folder)) return uploaded def cleanup_remote(cfg: dict, remote_sftp_path: Optional[str]) -> None: """ Elimina el .bak del servidor remoto vía SFTP. Best-effort: registra pero no lanza, para no enmascarar el resultado real del job. Se invoca siempre en `finally`. """ if not remote_sftp_path: return try: client = _connect(cfg) except SFTPCopyError as e: app_logger.error(f"No se pudo conectar para limpiar el .bak remoto: {e}") return try: sftp = client.open_sftp() try: sftp.remove(remote_sftp_path) app_logger.info(f"Inbox remoto limpiado: {remote_sftp_path}") except FileNotFoundError: pass finally: sftp.close() except Exception as e: app_logger.error(f"No se pudo limpiar el .bak remoto ({remote_sftp_path}): {e}") finally: client.close()