Dos bloqueos que impedían avanzar con la instalación en Linux. 1. La validación de PANEL_PUBLIC_URL solo comparaba que coincidiera con ORIGIN, y eso deja pasar el peor caso: que AMBAS valgan localhost, que es justo lo que produce el compose de desarrollo. Ahí no avisaba nada y la instalación sembraba loopback en el config/.env del destino, donde localhost es el propio destino y el agente acabaría hablando consigo mismo. Ahora se valida lo que de verdad importa —que la URL sea alcanzable desde otra máquina— y solo después el desajuste con ORIGIN. El mensaje también dejaba un consejo peligroso: "comenta PANEL_PUBLIC_URL para que tome ORIGIN" solo vale si ORIGIN sirve para sembrar; con ORIGIN en localhost, seguirlo empeora las cosas. Ese consejo ahora es condicional. De paso se corrige el docstring, que afirmaba que un ORIGIN equivocado se habría manifestado por la protección CSRF de adapter-node. No es cierto: svelte.config.js tiene csrf.checkOrigin en false, así que el Origin de los POST nunca se valida y ese razonamiento llevaba a conclusiones falsas. 2. El formulario fuerza mode='update' en cuanto el servidor tiene versión instalada, y una actualización exige que en la ruta destino ya viva algo. Eso hacía imposible mover una instalación a otra carpeta —por ejemplo al home, para instalar sin privilegios—: abortaba con "no hay una instalación". Se agrega una confirmación explícita siguiendo el molde de platformAck: el ack lleva la RUTA confirmada y no un booleano, así que una casilla marcada deja de valer si después se cambia el destino, y el servidor revalida en vez de confiar en la UI. La casilla advierte además que el agente anterior sigue corriendo: los dos reportarían con el mismo instance_key y se pisarían la carpeta de entrada registrada, que es por donde el panel enruta los respaldos. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
311 lines
13 KiB
TypeScript
311 lines
13 KiB
TypeScript
/**
|
|
* 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<CrasPlatform, string> = {
|
|
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';
|
|
}
|