# backup_unifi.py # # Respaldo automático de UniFi OS Server 5.1.21 (LXC en Proxmox) # con UniFi Network Application 10.5.67 # # Dos estrategias en cascada — sin depender de la nube de UniFi: # # Estrategia 1 — SSH/SFTP (Principal): # Conecta por SSH al LXC y copia el archivo .unf más reciente desde # /var/lib/unifi/backup/autobackup/ directamente. No usa ninguna API. # Requiere: SSH habilitado en el LXC y pip install paramiko # # Estrategia 2 — API HTTP (Fallback): # Autenticación por sesión + CSRF token y descarga del último backup # vía /api/backup/download. Si no existe, reintenta con /cmd/backup. # No depende de la nube: todo es contra la IP local del LXC. # import os import re import sys import time import subprocess from datetime import datetime import requests import urllib3 # ── Importar paramiko (solo necesario para Estrategia 1 — SSH) ────────────── try: import paramiko PARAMIKO_AVAILABLE = True except ImportError: PARAMIKO_AVAILABLE = False urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # ═══════════════════════════════════════════════════════════════════════════════ # CONFIGURACIÓN # ═══════════════════════════════════════════════════════════════════════════════ # ── UniFi OS Server (LXC en Proxmox) ──────────────────────────────────────── UNIFI_HOST = "192.168.1.10" UNIFI_PORT = "11443" UNIFI_USER = "admin" UNIFI_PASS = "@Lasalle2599*" UNIFI_SITE = "default" UNIFI_BASE_URL = f"https://{UNIFI_HOST}:{UNIFI_PORT}" # ── SSH — Estrategia 1 ─────────────────────────────────────────────────────── # UniFi OS Server en LXC: el usuario SSH es normalmente "root" SSH_USER = "root" SSH_PASS = "@Lasalle2599*" # Contraseña root del LXC (ajustar si difiere) SSH_PORT = 22 SSH_KEY_PATH = "" # Ruta a clave privada (.pem / id_rsa). Dejar vacío para usar contraseña. # Rutas de backup del OS Server (.unifi) — análisis del instalador: # # server.conf: /var/lib/uosserver/server.conf (línea 5534 del .sh) # WEB_PORT leido de: grep '^WEB_PORT=' /var/lib/uosserver/server.conf → default 11443 # API del OS Server: https://HOST:11443/api/backup (POST = trigger) # https://HOST:11443/api/backup/download (GET = descarga .unifi) # API de sistema: https://HOST:11443/api/system (GET = health check) # # Rutas .unifi en el filesystem del LXC (buscadas por SSH): # /var/lib/uosserver/data/backups/ ← OS Server backups (.unifi) # /home/uosserver/.local/share/uosserver/backups/ # /data/unifi-os/backups/ # # Rutas .unf (Network App backups, fallback): # /var/lib/unifi/backup/autobackup/ ← CONFIRMADO: symlink real # /usr/lib/unifi/data/backup/autobackup/ ← CONFIRMADO: default instalador # Rutas SSH para OS Server backups (.unifi) — se prueban primero SSH_OS_SERVER_PATHS = [ "/var/lib/uosserver/data/backups", # ← OS Server (ruta principal) "/home/uosserver/.local/share/uosserver/backups", # OS Server (home alternativo) "/data/unifi-os/backups", # OS Server (variante) "/var/lib/uosserver/backups", # OS Server (variante plana) ] # Rutas SSH para Network App backups (.unf) — fallback SSH_NETWORK_PATHS = [ "/var/lib/unifi/backup/autobackup", # ← CONFIRMADO: resolución real del symlink "/usr/lib/unifi/data/backup/autobackup", # ← CONFIRMADO: default del instalador "/var/lib/unifi/backup", # Directorio padre alternativo ] # Si el backup más reciente es más viejo que esto (horas), se considera stale SSH_MAX_BACKUP_AGE_HOURS = 72 # Puerto del OS Server (confirmado: WEB_PORT en /var/lib/uosserver/server.conf, default 11443) UNIFI_NETWORK_PORT = "8443" # Puerto directo Network App (legacy fallback) UNIFI_NETWORK_URL = f"https://{UNIFI_HOST}:{UNIFI_NETWORK_PORT}" # ── NAS / Destino ──────────────────────────────────────────────────────────── if os.name == 'nt': DEFAULT_NAS_PATH = r"\\10.0.0.6\bak-unifi" else: DEFAULT_NAS_PATH = "/mnt/bak-unifi" NAS_PATH = os.getenv("NAS_PATH", DEFAULT_NAS_PATH) NAS_USER = "jenkins" NAS_PASS = "LSJenkins2026*" RETENTION_DAYS = 7 # ── Timeouts API ───────────────────────────────────────────────────────────── SYSINFO_TIMEOUT = (10, 15) BACKUP_CREATE_TIMEOUT = (15, 180) # Crear backup OS Server puede tardar ~2 min BACKUP_CMD_TIMEOUT = (15, 120) # /cmd/backup Network App DOWNLOAD_TIMEOUT = (15, 120) # ═══════════════════════════════════════════════════════════════════════════════ # UTILIDADES # ═══════════════════════════════════════════════════════════════════════════════ def sanitize_filename(text: str) -> str: """Elimina caracteres inválidos para nombres de archivos.""" return re.sub(r'[\\/*?:"<>| ]', '_', text) def _sep(title: str = ""): """Separador visual de sección.""" if title: print(f"\n{'─' * 4} {title} {'─' * (50 - len(title))}") else: print("─" * 60) # ═══════════════════════════════════════════════════════════════════════════════ # NAS # ═══════════════════════════════════════════════════════════════════════════════ def authenticate_nas_share(path: str, username: str, password: str) -> bool: """Asegura la disponibilidad del recurso NAS en Windows o Linux.""" print(f"[*] Verificando acceso al recurso NAS: {path}") if os.path.exists(path): print("[+] Conexión al recurso NAS activa y accesible.") return True if os.name == 'nt': cmd = f'net use "{path}" "{password}" /user:"{username}"' try: res = subprocess.run(cmd, shell=True, capture_output=True, text=True) if res.returncode == 0 or os.path.exists(path): print("[+] Conexión SMB establecida con éxito en Windows.") return True else: print(f"[!] Advertencia 'net use': {res.stderr.strip()}") except Exception as e: print(f"[!] Error al ejecutar 'net use': {e}") else: print(f"[!] La ruta '{path}' no existe o no está montada.") try: os.makedirs(path, exist_ok=True) if os.path.exists(path): print("[+] Directorio creado/verificado exitosamente.") return True except Exception as e: print(f"[!] No se pudo crear el directorio {path}: {e}") return os.path.exists(path) def cleanup_old_backups(directory_path: str, days_to_keep: int = 7): """Elimina archivos de backup (.unf, .unifi) que superen los días de retención.""" print(f"\n[*] Ejecutando limpieza de archivos antiguos (Retención: {days_to_keep} días)...") if not os.path.exists(directory_path): print(f"[!] La ruta {directory_path} no está disponible para limpieza.") return cutoff_time = datetime.now().timestamp() - (days_to_keep * 86400) deleted_count = 0 kept_count = 0 try: files = [ f for f in os.listdir(directory_path) if f.endswith(".unf") or f.endswith(".unifi") ] for file_name in files: file_path = os.path.join(directory_path, file_name) if not os.path.isfile(file_path): continue if os.path.getmtime(file_path) < cutoff_time: try: os.remove(file_path) print(f" [-] Eliminado por antigüedad (>{days_to_keep}d): {file_name}") deleted_count += 1 except Exception as err: print(f" [!] Error al eliminar {file_name}: {err}") else: kept_count += 1 print(f"[+] Limpieza finalizada: {deleted_count} eliminado(s), {kept_count} conservado(s).") except Exception as e: print(f"[!] Error al escanear directorio de backups: {e}") # ═══════════════════════════════════════════════════════════════════════════════ # ESTRATEGIA 1 — SSH / SFTP # Accede directamente al filesystem del LXC. No depende de ninguna API. # ═══════════════════════════════════════════════════════════════════════════════ def _sftp_find_files(sftp, paths: list[str], extensions: tuple[str, ...]) -> tuple[str, list] | None: """ Busca en las rutas dadas el primer directorio que contenga archivos con alguna de las extensiones indicadas. Retorna (ruta, lista_de_entries) o None. """ for remote_path in paths: try: entries = sftp.listdir_attr(remote_path) found = [e for e in entries if any(e.filename.endswith(ext) for ext in extensions)] if found: exts_found = set(os.path.splitext(e.filename)[1] for e in found) print(f"[+] Directorio de backup encontrado: {remote_path} " f"({len(found)} archivo(s): {', '.join(sorted(exts_found))})") return remote_path, found else: print(f"[i] {remote_path} existe pero no contiene {extensions}.") except IOError: print(f"[i] {remote_path} no encontrado en el LXC.") return None def backup_via_ssh() -> tuple[bytes, str] | None: """ Estrategia 1: SSH → SFTP al LXC de Proxmox. Busca en este orden: 1. Backup OS Server (.unifi) en SSH_OS_SERVER_PATHS ← PRIORITARIO 2. Backup Network App (.unf) en SSH_NETWORK_PATHS ← Fallback Retorna (contenido_bytes, extensión) o None si falló. """ if not PARAMIKO_AVAILABLE: print("[!] Librería 'paramiko' no instalada. Estrategia SSH omitida.") print(" → Instalar con: pip install paramiko") return None print(f"[*] Conectando por SSH a {UNIFI_HOST}:{SSH_PORT} (usuario: {SSH_USER})...") ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: connect_kwargs: dict = { "hostname": UNIFI_HOST, "port": SSH_PORT, "username": SSH_USER, "timeout": 15, "allow_agent": False, "look_for_keys": False, } if SSH_KEY_PATH and os.path.exists(SSH_KEY_PATH): connect_kwargs["key_filename"] = SSH_KEY_PATH print(f"[i] Usando clave privada: {SSH_KEY_PATH}") else: connect_kwargs["password"] = SSH_PASS ssh.connect(**connect_kwargs) print("[+] Conexión SSH establecida correctamente.") sftp = ssh.open_sftp() # ── Paso 1: buscar backups del OS Server (.unifi) ─────────────────────────── print("[*] Buscando backups del OS Server (.unifi)...") result = _sftp_find_files(sftp, SSH_OS_SERVER_PATHS, (".unifi",)) if not result: # ── Paso 2 (fallback): buscar backups de la Network App (.unf) ────────── print("[!] No se encontraron backups .unifi del OS Server.") print("[*] Buscando backups de la Network App (.unf) como alternativa...") result = _sftp_find_files(sftp, SSH_NETWORK_PATHS, (".unf",)) if not result: print("[!] No se encontró ninguna ruta de backups en el LXC.") print(" Para OS Server backups (.unifi): habilitar en OS Server UI → System → Backups") print(" Para Network App backups (.unf): Settings → System → Backups → Auto Backup → ON") sftp.close() ssh.close() return None remote_path, found_entries = result # Determinar extensión del tipo encontrado file_ext = ".unifi" if any(e.filename.endswith(".unifi") for e in found_entries) else ".unf" backup_type = "OS Server" if file_ext == ".unifi" else "Network App" # Seleccionar el archivo más reciente de ese tipo typed_entries = sorted( [e for e in found_entries if e.filename.endswith(file_ext)], key=lambda e: e.st_mtime or 0, reverse=True, ) newest = typed_entries[0] age_hours = (time.time() - (newest.st_mtime or 0)) / 3600 print(f"[i] Backup {backup_type} más reciente: {newest.filename} (hace {age_hours:.1f}h)") if age_hours > SSH_MAX_BACKUP_AGE_HOURS: print(f"[!] El backup tiene {age_hours:.1f}h (límite: {SSH_MAX_BACKUP_AGE_HOURS}h). " f"Puede estar desactualizado. Descargando de todas formas...") # Descargar vía SFTP remote_file_path = f"{remote_path}/{newest.filename}" print(f"[*] Descargando por SFTP: {remote_file_path}") t0 = time.time() with sftp.open(remote_file_path, "rb") as rf: content = rf.read() elapsed = time.time() - t0 sftp.close() ssh.close() print(f"[+] Descarga SSH completada en {elapsed:.1f}s — {len(content) / 1024:.1f} KB ({backup_type})") return content, file_ext except paramiko.AuthenticationException: print("[!] Fallo de autenticación SSH.") print(" Verificar SSH_USER y SSH_PASS en la configuración del script.") except paramiko.SSHException as e: print(f"[!] Error de protocolo SSH: {e}") except (TimeoutError, OSError) as e: print(f"[!] No se pudo conectar a {UNIFI_HOST}:{SSH_PORT} — {e}") print(" Verificar que SSH esté habilitado en el LXC de Proxmox.") except Exception as e: print(f"[!] Error inesperado en Estrategia SSH: {e}") finally: try: ssh.close() except Exception: pass return None # ═══════════════════════════════════════════════════════════════════════════════ # ESTRATEGIA 2 — API HTTP (Fallback) # Autenticación local por sesión — sin nube, sin UI de Ubiquiti. # ═══════════════════════════════════════════════════════════════════════════════ def _create_authenticated_session(base_url: str, username: str, password: str) -> requests.Session: """ Autentica en UniFi OS y retorna la sesión con CSRF token listo. UniFi OS 3.x/4.x/5.x requiere el CSRF token en todos los POST. """ session = requests.Session() # User-Agent de navegador para evitar rechazos por agente no reconocido session.headers.update({ "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/126.0.0.0 Safari/537.36" ), "Accept": "application/json", "Content-Type": "application/json", }) login_url = f"{base_url}/api/auth/login" print(f"[*] Autenticando en UniFi OS: POST {login_url}") resp = session.post( login_url, json={"username": username, "password": password}, verify=False, timeout=15, ) if resp.status_code not in (200, 201): raise PermissionError( f"Fallo de autenticación en UniFi OS (HTTP {resp.status_code}): {resp.text[:300]}" ) # Extraer CSRF token — necesario para POST en UniFi OS 3.x/4.x/5.x csrf_token = ( resp.headers.get("X-CSRF-Token") or resp.headers.get("x-csrf-token") or resp.headers.get("X-Csrf-Token") ) if csrf_token: session.headers.update({"X-CSRF-Token": csrf_token}) print(f"[i] CSRF token obtenido: {csrf_token[:20]}...") else: print("[i] Sin CSRF token en la respuesta (puede no ser requerido en esta versión).") print("[+] Autenticación por sesión exitosa.") return session def _get_system_info(session: requests.Session, base_url: str) -> tuple[str, str]: """Obtiene nombre y versión del sistema para el nombre del archivo. No crítico.""" model = "UniFi-OS-Server-5.1.21" version = "Network-10.5.67" try: url = f"{base_url}/proxy/network/api/s/{UNIFI_SITE}/stat/sysinfo" res = session.get(url, verify=False, timeout=SYSINFO_TIMEOUT) if res.status_code == 200: data = res.json().get("data", [{}])[0] name = data.get("name", "UniFi-OS-Server") ver = data.get("version", "10.5.67") model = sanitize_filename(f"UniFi_{name}") version = sanitize_filename(f"v{ver}") print(f"[+] Sistema: {model} — {version}") else: print(f"[i] sysinfo retornó HTTP {res.status_code}. Usando valores por defecto.") except Exception as e: print(f"[i] No se pudo obtener sysinfo: {e}. Usando valores por defecto.") return model, version def _try_create_os_server_backup(session: requests.Session, base_url: str) -> bool: """ Solicita al OS Server que cree un nuevo backup (.unifi). POST /api/backup — el OS Server genera el archivo y lo deja disponible para descargar con GET /api/backup/download. Retorna True si el trigger fue exitoso, False si falló. """ url = f"{base_url}/api/backup" print(f"[*] Solicitando creación de backup OS Server: POST {url}") try: t0 = time.time() resp = session.post(url, json={}, verify=False, timeout=BACKUP_CREATE_TIMEOUT) elapsed = time.time() - t0 print(f"[i] Respuesta en {elapsed:.1f}s — HTTP {resp.status_code}") if resp.status_code in (200, 201, 202): print(f"[+] Backup OS Server solicitado correctamente.") if elapsed < 5: # El servidor aceptó rápido: esperar que termine de generarlo print("[*] Esperando 10s para que el OS Server genere el archivo...") time.sleep(10) return True elif resp.status_code == 403: print("[!] HTTP 403 en POST /api/backup — permisos insuficientes.") elif resp.status_code == 404: print("[i] POST /api/backup no existe en esta versión. Continuando con descarga directa.") else: print(f"[!] HTTP {resp.status_code} al crear backup: {resp.text[:200]}") except requests.exceptions.Timeout: # Timeout puede ser normal si el servidor tardó en generar el backup print(f"[!] Timeout esperando respuesta de POST /api/backup. El backup puede haberse generado.") return True # Intentar descarga de todas formas except Exception as e: print(f"[!] Error en POST /api/backup: {e}") return False def _try_direct_download(session: requests.Session, base_url: str) -> bytes | None: """ Intenta GET /api/backup/download — descarga el último backup sin generar uno nuevo. Este endpoint descarga el archivo existente y no sufre el timeout silencioso de /cmd/backup. """ url = f"{base_url}/api/backup/download" print(f"[*] Intentando descarga directa: GET {url}") try: t0 = time.time() resp = session.get(url, verify=False, timeout=DOWNLOAD_TIMEOUT, stream=True) elapsed = time.time() - t0 print(f"[i] Respuesta en {elapsed:.1f}s — HTTP {resp.status_code}") if resp.status_code == 200: content = resp.content # Verificar que sea binario (.unf), no un JSON de error if len(content) > 1024 and not content.lstrip().startswith(b"{"): print(f"[+] Descarga directa exitosa — {len(content) / 1024:.1f} KB") return content else: print(f"[i] La respuesta parece JSON/error, no un archivo binario: {content[:150]}") elif resp.status_code == 404: print("[i] Endpoint /api/backup/download no existe en esta versión de UniFi OS.") elif resp.status_code == 403: print("[!] HTTP 403 en /api/backup/download — permisos insuficientes.") else: print(f"[!] HTTP {resp.status_code} en /api/backup/download.") except requests.exceptions.Timeout: print("[!] Timeout esperando /api/backup/download.") except Exception as e: print(f"[!] Error en /api/backup/download: {e}") return None def _try_cmd_backup(session: requests.Session, base_url: str) -> tuple[bytes, str] | None: """ Último recurso: endpoint clásico /cmd/backup. En UniFi Network 10.5.x puede funcionar si los permisos son correctos. Timeout reducido a BACKUP_CMD_TIMEOUT[1]s — si tarda más, es fallo silencioso. """ url = f"{base_url}/proxy/network/api/s/{UNIFI_SITE}/cmd/backup" print(f"[*] Intentando /cmd/backup (timeout: {BACKUP_CMD_TIMEOUT[1]}s): POST {url}") try: t0 = time.time() resp = session.post( url, json={"cmd": "backup", "days": 0}, verify=False, timeout=BACKUP_CMD_TIMEOUT, ) elapsed = time.time() - t0 print(f"[i] Respuesta en {elapsed:.1f}s — HTTP {resp.status_code}") if resp.status_code == 200: try: res_json = resp.json() data_list = res_json.get("data", []) if data_list and "url" in data_list[0]: relative_url = data_list[0]["url"] download_url = f"{base_url}{relative_url}" ext = ".unifi" if relative_url.endswith(".unifi") else ".unf" print(f"[+] Backup generado por /cmd/backup: {relative_url}") print("[*] Descargando archivo generado...") t1 = time.time() dl = session.get(download_url, verify=False, timeout=DOWNLOAD_TIMEOUT) print(f"[i] Descarga en {time.time() - t1:.1f}s — HTTP {dl.status_code}") if dl.status_code == 200: print(f"[+] /cmd/backup exitoso — {len(dl.content) / 1024:.1f} KB") return dl.content, ext else: print(f"[!] Respuesta inesperada de /cmd/backup: {res_json}") except Exception as e: print(f"[!] Error procesando respuesta de /cmd/backup: {e}") elif resp.status_code == 403: print("[!] HTTP 403 en /cmd/backup — el usuario necesita 'Full Management' en Network.") else: print(f"[!] HTTP {resp.status_code} en /cmd/backup: {resp.text[:200]}") except requests.exceptions.ReadTimeout: print(f"[!] /cmd/backup no respondió en {BACKUP_CMD_TIMEOUT[1]}s (fallo silencioso conocido).") print(" → Habilitar SSH en el LXC para que la Estrategia 1 funcione.") except requests.exceptions.ConnectionError as e: print(f"[!] Error de conexión en /cmd/backup: {e}") except Exception as e: print(f"[!] Error inesperado en /cmd/backup: {e}") return None def backup_via_api() -> tuple[bytes, str, str, str] | None: """ Estrategia 2: backup vía API HTTP local (sin nube). Prueba en este orden: [OS] POST /api/backup → trigger creación backup OS Server (.unifi) GET /api/backup/download → descarga el .unifi generado [A] GET /api/backup/download → descarga el último .unifi disponible (sin trigger) [B] POST /proxy/network/.../cmd/backup → backup Network App (.unf) vía proxy [C] Puerto 8443 directo → /cmd/backup sin proxy (Network App) Referencia: instalador línea 5534: WEB_PORT en /var/lib/uosserver/server.conf → 11443 """ model, version = "UniFi-OS-Server-5.1.21", "Network-10.5.67" # ── Autenticación única para todos los intentos vía 11443 ─────────────── print(f"[*] Autenticando en OS Server: {UNIFI_BASE_URL}") try: session = _create_authenticated_session(UNIFI_BASE_URL, UNIFI_USER, UNIFI_PASS) model, version = _get_system_info(session, UNIFI_BASE_URL) except PermissionError as e: print(f"[!] Autenticación fallida: {e}") return None except Exception as e: print(f"[!] No se pudo autenticar: {e}") return None # ── [OS] Intentar crear + descargar backup del OS Server (.unifi) ──────── print("\n[*] [OS] Intentando backup del OS Server (.unifi)...") triggered = _try_create_os_server_backup(session, UNIFI_BASE_URL) if triggered: content = _try_direct_download(session, UNIFI_BASE_URL) if content: print("[+] [OS] Backup OS Server (.unifi) obtenido correctamente.") return content, ".unifi", model, version print("[!] [OS] Trigger aceptado pero descarga falló. Continuando...") # ── [A] Intentar descarga directa del último backup disponible ─────────── print("\n[*] [A] Descarga directa del último backup disponible...") content = _try_direct_download(session, UNIFI_BASE_URL) if content: # Determinar extensión por el contenido ext = ".unifi" if b"unifi_os_backup" in content[:200] else ".unf" print(f"[+] [A] Backup descargado directamente ({ext}).") return content, ext, model, version # ── [B] Fallback: backup Network App vía proxy (puerto 11443) ─────────── print("\n[*] [B] Intentando backup Network App vía proxy (puerto 11443)...") result = _try_cmd_backup(session, UNIFI_BASE_URL) if result: content, ext = result return content, ext, model, version # ── [C] Fallback: Network App directa (puerto 8443) ───────────────────── print(f"\n[*] [C] Intentando Network App directa: {UNIFI_NETWORK_URL}") for login_path in ["/api/auth/login", "/api/login"]: try: session_c = requests.Session() session_c.headers.update({ "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" ), "Accept": "application/json", "Content-Type": "application/json", }) resp_login = session_c.post( f"{UNIFI_NETWORK_URL}{login_path}", json={"username": UNIFI_USER, "password": UNIFI_PASS}, verify=False, timeout=15, ) if resp_login.status_code not in (200, 201): continue csrf = resp_login.headers.get("X-CSRF-Token") or resp_login.headers.get("x-csrf-token") if csrf: session_c.headers.update({"X-CSRF-Token": csrf}) print(f"[+] [C] Autenticación exitosa en {login_path}") result = _try_cmd_backup(session_c, UNIFI_NETWORK_URL) if result: content, ext = result return content, ext, model, version content = _try_direct_download(session_c, UNIFI_NETWORK_URL) if content: return content, ".unifi", model, version break except Exception as e: print(f"[i] [C] Error con {login_path}: {e}") continue print("[!] [Estrategia 2 — API] Todos los intentos fallaron.") return None # ═══════════════════════════════════════════════════════════════════════════════ # MAIN # ═══════════════════════════════════════════════════════════════════════════════ def main(): print("=" * 60) print(" RESPALDO UNIFI OS SERVER 5.1.21 / NETWORK 10.5.67") print(f" LXC Proxmox — {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") print("=" * 60) # ── 1. Acceso al NAS ──────────────────────────────────────────────────── if not authenticate_nas_share(NAS_PATH, NAS_USER, NAS_PASS): print(f"[ERROR CRÍTICO] No se puede acceder a la ruta destino: {NAS_PATH}") sys.exit(1) backup_content: bytes | None = None file_ext = ".unf" model = "UniFi-OS-Server-5.1.21" version = "Network-10.5.67" # ── 2. Estrategia 1: SSH / SFTP ───────────────────────────────────────── _sep("Estrategia 1: SSH / SFTP (principal)") result_ssh = backup_via_ssh() if result_ssh: backup_content, file_ext = result_ssh print("[+] Backup obtenido por SSH exitosamente.") else: print("[!] Estrategia 1 (SSH) no disponible o sin autobackups. Continuando...") # ── 3. Estrategia 2: API HTTP ──────────────────────────────────────────── if backup_content is None: _sep("Estrategia 2: API HTTP (fallback)") result_api = backup_via_api() if result_api: backup_content, file_ext, model, version = result_api print("[+] Backup obtenido por API exitosamente.") else: print("[!] Estrategia 2 (API) también falló.") # ── 4. Verificar que tenemos contenido ────────────────────────────────── if backup_content is None: print() print("=" * 60) print("[ERROR CRÍTICO] RESPALDO FALLIDO — Ninguna estrategia tuvo éxito.") print() print(" Pasos para resolver:") print() print(" [SSH] 1. Habilitar SSH en el LXC de Proxmox (si no está activo)") print(" y asegurarse que SSH_PASS en este script sea correcto.") print() print(" [SSH] 2. Habilitar autobackups en UniFi UI:") print(" Settings → System → Backups → Auto Backup → ON") print(" Esperar a que genere el primer archivo .unf.") print() print(" [API] 3. Verificar permisos del usuario admin:") print(" Settings → Admins & Users → admin") print(" → Network: Full Management (no solo View)") print("=" * 60) sys.exit(1) # ── 5. Guardar en NAS ─────────────────────────────────────────────────── timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") # Prefijo según tipo de backup: OS Server (.unifi) o Network App (.unf) if file_ext == ".unifi": prefix = "UniFi_OS_Server" else: prefix = "UniFi_Network_App" safe_version = sanitize_filename(version) filename = f"{prefix}_{safe_version}_{timestamp}{file_ext}" destination_file = os.path.join(NAS_PATH, filename) print(f"\n[*] Guardando en NAS: {filename}") try: with open(destination_file, "wb") as f: f.write(backup_content) size_kb = len(backup_content) / 1024 print() print("=" * 60) print("[ÉXITO] RESPALDO COMPLETADO") print(f" Ruta : {destination_file}") print(f" Tamaño: {size_kb:.2f} KB") print("=" * 60) except Exception as e: print(f"[ERROR CRÍTICO] Falló la escritura del archivo en el NAS: {e}") sys.exit(1) # ── 6. Limpieza por retención ──────────────────────────────────────────── cleanup_old_backups(NAS_PATH, RETENTION_DAYS) if __name__ == "__main__": main()