/** * Comparación de versiones de CloudRestoreAS y normalización de plataforma. * * Módulo PURO (sin acceso a red ni a BD) para que sea testeable y usable tanto en el * servidor como en el navegador. Es la pieza que decide si un servidor tiene una versión * vieja, así que un error aquí se traduce en actualizaciones que nunca se ofrecen o que se * ofrecen en reversa. */ /** Plataformas para las que se publican artefactos (espejo del CHECK de cras_releases). */ export const CRAS_PLATFORMS = ['windows', 'linux'] as const; export type CrasPlatform = (typeof CRAS_PLATFORMS)[number]; export const DEFAULT_ARCH = 'x86_64'; export function isCrasPlatform(value: unknown): value is CrasPlatform { return typeof value === 'string' && (CRAS_PLATFORMS as readonly string[]).includes(value); } /** * Versión válida: solo puntos y números (1.1.0, 26.7.1.4). CloudRestoreAS lo garantiza en * package-release.sh, que aborta el empaquetado si el formato no cumple. */ const VERSION_RE = /^\d+(\.\d+){0,3}$/; export function isValidVersion(value: unknown): boolean { return typeof value === 'string' && VERSION_RE.test(value.trim()); } /** Convierte "1.10.2" en [1, 10, 2]. Devuelve null si no es una versión comparable. */ export function parseVersion(value: string): number[] | null { const raw = String(value ?? '').trim(); if (!VERSION_RE.test(raw)) return null; return raw.split('.').map((part) => Number.parseInt(part, 10)); } /** * Orden numérico por componente, rellenando con ceros: "1.2" == "1.2.0" y "1.10" > "1.9" * (una comparación de cadenas daría lo contrario, que es el error clásico). * * Devuelve -1 si a < b, 0 si son equivalentes, 1 si a > b, y null si alguna no es * comparable — el llamador debe tratar null como "no sé", nunca como "iguales". */ export function compareVersions(a: string, b: string): -1 | 0 | 1 | null { const left = parseVersion(a); const right = parseVersion(b); if (!left || !right) return null; const length = Math.max(left.length, right.length); for (let i = 0; i < length; i++) { const x = left[i] ?? 0; const y = right[i] ?? 0; if (x < y) return -1; if (x > y) return 1; } return 0; } /** * ¿`candidate` es más nueva que `installed`? * * Sin versión instalada (servidor sin agente todavía) cuenta como que sí hay algo que * instalar. Si alguna versión no es comparable devuelve false: es preferible no ofrecer * una actualización que ofrecer un downgrade por una comparación inválida. */ export function isNewer(candidate: string, installed: string | null | undefined): boolean { if (!isValidVersion(candidate)) return false; const current = (installed ?? '').trim(); if (!current) return true; return compareVersions(candidate, current) === 1; } /** * Normaliza el texto libre de `restore_targets.os` a una plataforma. * * Ese campo lo captura a mano el operador ("Windows Server 2019", "Ubuntu 22.04"), así que * solo sirve como respaldo para la PRIMERA instalación, cuando el agente todavía no ha * reportado su plataforma real. Devuelve null si no se puede decidir — la UI entonces pide * elegir a mano en lugar de adivinar y mandar el binario equivocado. */ export function osToPlatform(os: string | null | undefined): CrasPlatform | null { const text = (os ?? '').trim().toLowerCase(); if (!text) return null; if (text.includes('windows') || text.includes('win server') || /\bwin\b/.test(text)) { return 'windows'; } const linuxHints = [ 'linux', 'ubuntu', 'debian', 'centos', 'rhel', 'red hat', 'redhat', 'fedora', 'suse', 'rocky', 'alma', 'oracle linux' ]; if (linuxHints.some((hint) => text.includes(hint))) return 'linux'; return null; } /** * Plataforma efectiva de un destino: lo que reportó el agente manda sobre el texto libre. * `reported` viene de cloudrestore_status.platform y es autoritativo. */ export function effectivePlatform( reported: string | null | undefined, os: string | null | undefined ): CrasPlatform | null { const value = (reported ?? '').trim().toLowerCase(); if (isCrasPlatform(value)) return value; return osToPlatform(os); } /** Arquitectura efectiva; si el agente no la reportó se asume la única que se publica hoy. */ export function effectiveArch(reported: string | null | undefined): string { const value = (reported ?? '').trim().toLowerCase(); return value || DEFAULT_ARCH; } // ============================================================================ // Ruta de instalación // // Es un espacio de rutas SEPARADO de `input_folder` (la carpeta que vigila el agente, que // puede ser un share UNC en otra máquina) y de `remote_inbox_path`. La reporta el agente // (`APP_DIR` = carpeta del ejecutable), y ahí vive `config/.env` con las rutas de trabajo // que el operador haya personalizado. Instalar en la ruta equivocada crea una segunda // instalación con la configuración default y deja huérfano el `.env` personalizado. // ============================================================================ export const DEFAULT_INSTALL_PATHS: Record = { linux: '/opt/cloudrestoreas', windows: 'C:\\Aduanasoft\\CloudRestoreAS' }; /** * Valida una ruta de instalación. Se aplica en serio porque el valor llega **por red** desde * el agente y termina interpolado en un comando de shell y en un script de PowerShell. * * Se rechazan las rutas UNC a propósito: el bootstrap escribe `config/` junto al ejecutable, y * una tarea programada como SYSTEM no ve shares de red, así que un agente instalado en un * share no podría arrancar. */ export function isSafeInstallPath(path: unknown, platform: CrasPlatform): boolean { const value = String(path ?? '').trim(); if (!value || value.length > 400) return false; // Comillas, metacaracteres de shell y saltos de línea: fuera, sin excepciones. if (/["'`$;&|<>\n\r*?()[\]{}!]/.test(value)) return false; if (value.includes('..')) return false; if (platform === 'windows') { if (value.startsWith('\\\\')) return false; // UNC return /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]*$/.test(value); } if (value.startsWith('//')) return false; // equivalente POSIX de un share return /^\/[A-Za-z0-9 ._/-]*$/.test(value); } /** * Ruta efectiva: la que reportó el agente si es válida, o el default de la plataforma. * Devuelve null si no se conoce la plataforma — sin ella no hay default que aplicar. */ export function effectiveInstallPath( reported: string | null | undefined, platform: CrasPlatform | null | undefined ): string | null { if (!platform || !isCrasPlatform(platform)) return null; const value = String(reported ?? '').trim(); if (value && isSafeInstallPath(value, platform)) { return value.replace(/[/\\]+$/, '') || value; } return DEFAULT_INSTALL_PATHS[platform]; } /** True si la ruta reportada por el agente es la default de su plataforma. */ export function isDefaultInstallPath( path: string | null | undefined, platform: CrasPlatform | null | undefined ): boolean { if (!platform || !isCrasPlatform(platform)) return false; return String(path ?? '').trim() === DEFAULT_INSTALL_PATHS[platform]; } /** * ¿La ruta de instalación cae dentro de una carpeta de trabajo del agente? * * Instalar el binario dentro de `Entrada`/`Procesados` sería grave: el agente vigila esa * carpeta y trataría de procesar sus propios archivos como si fueran respaldos. * * La relación se mira en UN solo sentido, y eso es deliberado. La carpeta de trabajo **debajo** * de la de instalación es el layout normal, no un error: `constants.py` del agente define * `DIR_ENTRADA = APP_DIR / "Entrada"`, así que una instalación correcta en * `/opt/cloudrestoreas` reporta `input_folder = /opt/cloudrestoreas/Entrada`. Cuando esto también * rechazaba ese caso, ninguna instalación ni actualización podía pasar: fallaba en * resolveInstallPath, antes de abrir el run, con un mensaje que decía justo lo contrario de lo * que ocurría. Solo se salvaban las instalaciones fuera de la ruta por omisión, por accidente. */ export function isInsideWorkFolder( installPath: string | null | undefined, workFolders: (string | null | undefined)[] ): boolean { const norm = (p: string | null | undefined) => String(p ?? '') .trim() .replace(/\\/g, '/') .replace(/\/+$/, '') .toLowerCase(); const target = norm(installPath); if (!target) return false; return workFolders.some((folder) => { const base = norm(folder); if (!base) return false; return target === base || target.startsWith(`${base}/`); }); } /** Loopback en cualquiera de sus formas. `URL.hostname` devuelve IPv6 entre corchetes. */ function isLoopbackHost(hostname: string): boolean { const h = hostname.trim().toLowerCase().replace(/^\[|\]$/g, ''); return h === 'localhost' || h === '::1' || h === '0.0.0.0' || /^127\./.test(h); } /** * Motivo por el que una URL del panel NO se puede sembrar en un agente, o null si sirve. * * Lo que importa no es que `PANEL_PUBLIC_URL` y `ORIGIN` coincidan, sino que la URL sea * **alcanzable desde otra máquina**: se escribe en el config/.env del servidor destino, al * instalar y también al actualizar. Comparar solo que coincidan deja pasar el peor caso — que * ambas valgan `localhost`, que es lo que produce el compose de desarrollo—, y ahí cada agente * acabaría hablando consigo mismo en su propio servidor. */ export function unseedablePanelUrlReason(url: string | null | undefined): string | null { const value = String(url ?? '').trim(); if (!value) return 'no está configurada'; let parsed: URL; try { parsed = new URL(value); } catch { return `no es una URL válida (${value})`; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return `usa el protocolo ${parsed.protocol.replace(':', '')} y el agente habla HTTP(S)`; } if (isLoopbackHost(parsed.hostname)) { return `apunta a ${parsed.hostname}, que desde el servidor destino es ese mismo servidor`; } return null; } /** Normaliza una ruta para compararla: sin separador final y sin espacios alrededor. */ function normalizeInstallPath(path: string | null | undefined): string { return String(path ?? '') .trim() .replace(/[/\\]+$/, ''); } /** * ¿El operador confirmó que quiere una instalación LIMPIA en esta ruta? * * Cuando un servidor ya tiene una versión instalada, el panel pide `update`, y una actualización * exige que en la ruta destino ya viva una instalación (`assertExistingInstall`). Esa guarda es * correcta —evita crear una segunda instalación con la configuración por omisión y dejar huérfano * el .env personalizado—, pero bloquea el caso legítimo de mover la instalación a otra ruta, por * ejemplo al home para instalar sin privilegios. * * El ack lleva la RUTA confirmada y no un booleano, igual que `installPlatformVerdict` lleva la * plataforma: así una casilla que quedó marcada no sigue valiendo si después se cambia la ruta. */ export function isCleanInstallConfirmed( requestedInstallPath: string | null | undefined, ack: string | null | undefined ): boolean { const requested = normalizeInstallPath(requestedInstallPath); if (!requested) return false; return normalizeInstallPath(ack) === requested; } /** Etiqueta legible para la UI. */ export function platformLabel(platform: string | null | undefined): string { if (platform === 'windows') return 'Windows'; if (platform === 'linux') return 'Linux'; return 'Sin determinar'; } /** * ¿Se puede instalar un artefacto de `releasePlatform` en un destino cuya plataforma efectiva es * `targetPlatform`? * * Cuando el panel NO pudo determinar la plataforma del destino no se adivina: se exige una * confirmación explícita del operador (`ack`) que coincida con la plataforma del artefacto. Sin * esto, el modal preseleccionaba el primer artefacto del catálogo y un solo clic podía mandar el * .tar.gz de Linux a un Windows; el fallo aparecía hasta el destino, ya con la sesión SSH abierta * y un run fallido en la bitácora. * * La misma función se usa en la UI (para habilitar el botón) y en la action `install` (como red * de seguridad ante un formulario viejo o un POST a mano), así que la regla vive en un solo lugar. */ export function installPlatformVerdict( targetPlatform: CrasPlatform | null | undefined, releasePlatform: CrasPlatform, ack: string | null | undefined ): 'ok' | 'mismatch' | 'needs-ack' { if (targetPlatform) return targetPlatform === releasePlatform ? 'ok' : 'mismatch'; return (ack ?? '').trim().toLowerCase() === releasePlatform ? 'ok' : 'needs-ack'; }