diff --git a/src/lib/cras-version.test.ts b/src/lib/cras-version.test.ts index 3b8f4cb..7721d04 100644 --- a/src/lib/cras-version.test.ts +++ b/src/lib/cras-version.test.ts @@ -12,7 +12,9 @@ import { effectivePlatform, installPlatformVerdict, isCrasPlatform, + isCleanInstallConfirmed, isInsideWorkFolder, + unseedablePanelUrlReason, isNewer, isValidVersion, osToPlatform, @@ -260,3 +262,69 @@ describe('isInsideWorkFolder', () => { expect(isInsideWorkFolder('', ['/srv/entrada'])).toBe(false); }); }); + +/** + * URL del panel que se siembra en el agente. + * + * El aviso original solo comparaba que PANEL_PUBLIC_URL y ORIGIN coincidieran, y eso deja pasar + * el peor caso: que ambas valgan localhost, que es justo lo que produce el compose de desarrollo. + * Lo que hay que exigir es que la URL sea alcanzable desde OTRA máquina. + */ +describe('unseedablePanelUrlReason', () => { + it('rechaza loopback en todas sus formas, aunque coincida con ORIGIN', () => { + // Es el hueco que se está cerrando: en el servidor destino, localhost es ese servidor. + expect(unseedablePanelUrlReason('https://localhost:3000')).toMatch(/localhost/); + expect(unseedablePanelUrlReason('http://127.0.0.1:3000')).toMatch(/127\.0\.0\.1/); + expect(unseedablePanelUrlReason('http://127.1.2.3:3000')).not.toBeNull(); + expect(unseedablePanelUrlReason('http://[::1]:3000')).not.toBeNull(); + expect(unseedablePanelUrlReason('http://0.0.0.0:3000')).not.toBeNull(); + }); + + it('acepta una URL alcanzable, con y sin puerto', () => { + expect(unseedablePanelUrlReason('https://cpanel-a24.aduanasoft.com:3000')).toBeNull(); + // Sin puerto también pasa: el problema de esa es el desajuste con ORIGIN, que es un + // aviso distinto. Mezclar los dos haría imposible saber cuál corregir. + expect(unseedablePanelUrlReason('https://cpanel-a24.aduanasoft.com')).toBeNull(); + expect(unseedablePanelUrlReason('http://10.0.0.5:3000')).toBeNull(); + }); + + it('rechaza vacío, basura y protocolos que el agente no habla', () => { + expect(unseedablePanelUrlReason('')).toMatch(/no está configurada/); + expect(unseedablePanelUrlReason(null)).toMatch(/no está configurada/); + expect(unseedablePanelUrlReason('cpanel-a24.aduanasoft.com:3000')).not.toBeNull(); + expect(unseedablePanelUrlReason('ftp://cpanel-a24.aduanasoft.com')).toMatch(/ftp/); + }); +}); + +/** + * Confirmación de instalación limpia en otra ruta. + * + * Sin esto no se puede mover una instalación existente —por ejemplo al home, para instalar sin + * privilegios—: el panel pide `update` en cuanto hay versión instalada y assertExistingInstall + * exige que ya viva algo en la ruta destino. + */ +describe('isCleanInstallConfirmed', () => { + it('solo confirma si el ack es exactamente la ruta pedida', () => { + expect(isCleanInstallConfirmed('/home/srvmid_db/cras', '/home/srvmid_db/cras')).toBe(true); + expect(isCleanInstallConfirmed('/home/srvmid_db/cras', '/opt/cloudrestoreas')).toBe(false); + }); + + it('un ack de otra ruta NO sirve: la casilla no queda encendida si se cambia el destino', () => { + // Es la razón de que el ack lleve la ruta y no un booleano. + expect(isCleanInstallConfirmed('/home/otro/cras', '/home/srvmid_db/cras')).toBe(false); + expect(isCleanInstallConfirmed('/opt/cloudrestoreas', 'true')).toBe(false); + expect(isCleanInstallConfirmed('/opt/cloudrestoreas', 'on')).toBe(false); + }); + + it('tolera el separador final y los espacios, que no cambian la ruta', () => { + expect(isCleanInstallConfirmed('/home/srvmid_db/cras', '/home/srvmid_db/cras/')).toBe(true); + expect(isCleanInstallConfirmed('C:\\Aduanasoft\\CRAS', ' C:\\Aduanasoft\\CRAS\\ ')).toBe(true); + }); + + it('sin ack, o sin ruta pedida, nunca confirma', () => { + expect(isCleanInstallConfirmed('/opt/cloudrestoreas', null)).toBe(false); + expect(isCleanInstallConfirmed('/opt/cloudrestoreas', '')).toBe(false); + expect(isCleanInstallConfirmed('', '')).toBe(false); + expect(isCleanInstallConfirmed(null, null)).toBe(false); + }); +}); diff --git a/src/lib/cras-version.ts b/src/lib/cras-version.ts index 8914083..b2a8b2d 100644 --- a/src/lib/cras-version.ts +++ b/src/lib/cras-version.ts @@ -218,6 +218,68 @@ export function isInsideWorkFolder( }); } +/** 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'; diff --git a/src/routes/versiones-cras/+page.server.ts b/src/routes/versiones-cras/+page.server.ts index 6d5e7eb..9301e3b 100644 --- a/src/routes/versiones-cras/+page.server.ts +++ b/src/routes/versiones-cras/+page.server.ts @@ -35,10 +35,13 @@ import { type PrunePlan } from '$lib/server/cras-artifacts'; import { + effectiveInstallPath, installPlatformVerdict, + isCleanInstallConfirmed, isInsideWorkFolder, isSafeInstallPath, - platformLabel + platformLabel, + unseedablePanelUrlReason } from '$lib/cras-version'; import { setCloudRestoreInstallPath } from '$lib/server/controldesk-pg'; import { syncCrasReleasesFromGitea } from '$lib/server/cras-sync'; @@ -77,20 +80,41 @@ function panelApiToken(): string { } /** - * Aviso cuando PANEL_PUBLIC_URL apunta a un origen distinto del que sirve el panel. + * Problemas de la URL del panel que se siembra en el agente. * - * Esta URL se siembra en el config/.env del agente, y el instalador la reescribe en CADA + * Esta URL se escribe en el config/.env del destino, y el instalador la reescribe en CADA * actualización, no solo al instalar. Una URL equivocada no falla al capturarla: rompe el servidor * la próxima vez que se actualice, y de golpe —el agente deja de reportar y de resolver rutas de * restauración—. En producción le faltaba el puerto y el 443 de ese host lo sirve otro servicio, * así que todo lo que pidiera el agente habría caído en un 404 ajeno. * - * Se compara contra ORIGIN porque el navegador ya lo validó: adapter-node rechaza los POST cuyo - * Origin no cuadre, así que si la pantalla responde, ORIGIN es el origen real del panel. + * Se revisan DOS cosas distintas, en este orden: + * + * 1. Que la URL efectiva sea **sembrable** (alcanzable desde otra máquina). Es lo que de verdad + * rompe, y no se puede deducir de que coincida con ORIGIN. + * 2. Que PANEL_PUBLIC_URL y ORIGIN coincidan, como señal de que una de las dos está mal escrita. + * + * Ojo con una tentación: NO se puede suponer que ORIGIN es correcto porque la pantalla responde. + * `svelte.config.js` tiene `csrf.checkOrigin: false`, así que adapter-node no valida el Origin de + * los POST y un ORIGIN equivocado nunca se manifiesta por esa vía. */ -function panelUrlOriginMismatch(): string | null { +function panelUrlProblem(): string | null { const declared = (env.PANEL_PUBLIC_URL || '').trim(); const origin = (env.ORIGIN || '').trim(); + const effective = declared || origin; + + // Primero lo que de verdad rompe: que la URL sembrada no sea alcanzable desde otra máquina. + // Comparar solo que declared y origin coincidan dejaba pasar el peor caso —ambas en + // localhost, que es lo que produce el compose de desarrollo— sin decir nada. + const unseedable = unseedablePanelUrlReason(effective); + if (unseedable) { + const which = declared ? 'PANEL_PUBLIC_URL' : 'ORIGIN'; + return ( + `La URL que se sembrará en el agente (${which}) ${unseedable}. Se escribe en el ` + + 'config/.env del servidor destino al instalar y al actualizar, así que tiene que ser ' + + 'alcanzable DESDE ese servidor.' + ); + } if (!declared || !origin) return null; let declaredUrl: URL; @@ -103,11 +127,18 @@ function panelUrlOriginMismatch(): string | null { } // `host` incluye el puerto, que es justo lo que se omitió en producción. if (declaredUrl.host === originUrl.host) return null; + + // El consejo de "comenta PANEL_PUBLIC_URL y toma ORIGIN" solo vale si ORIGIN sirve para + // sembrar. Darlo a ciegas cuando ORIGIN es loopback empeora las cosas: sembraría localhost. + const originUnseedable = unseedablePanelUrlReason(origin); return ( `PANEL_PUBLIC_URL (${declaredUrl.origin}) no coincide con el origen del panel ` + `(${originUrl.origin}). Es la URL que se siembra en el agente al instalar y al actualizar, ` + - 'así que apuntaría a otro servicio. Corrige el puerto o comenta PANEL_PUBLIC_URL para que ' + - 'tome ORIGIN.' + 'así que apuntaría a otro servicio. ' + + (originUnseedable + ? `Y ORIGIN tampoco sirve para sembrar: ${originUnseedable}. Corrige AMBAS con una URL ` + + 'alcanzable desde los servidores de restauración, incluyendo el puerto.' + : 'Corrige el puerto, o comenta PANEL_PUBLIC_URL para que tome ORIGIN.') ); } @@ -200,9 +231,9 @@ export const load: PageServerLoad = async ({ cookies }) => { 'PANEL_PUBLIC_URL (u ORIGIN) no está configurado: el agente no sabría a qué URL reportar.' ); } - const originMismatch = panelUrlOriginMismatch(); - if (originMismatch) { - configWarnings.push(originMismatch); + const urlProblem = panelUrlProblem(); + if (urlProblem) { + configWarnings.push(urlProblem); } return { @@ -491,7 +522,7 @@ export const actions: Actions = { if (!targetId || !releaseId) { return fail(400, { error: 'Servidor o versión inválidos.' }); } - const mode = modeRaw === 'update' ? 'update' : 'install'; + const requestedMode = modeRaw === 'update' ? 'update' : 'install'; // Lista blanca explícita en vez de una cadena de ternarios: con cuatro modos, el patrón // anterior convertía cualquier valor no reconocido en 'service', que es justo el que // exige privilegios. Un formulario viejo pediría el modo más restrictivo sin quererlo. @@ -561,9 +592,31 @@ export const actions: Actions = { // Se aborta antes de tocar el servidor: esta URL se escribe sobre el config/.env del // destino también al ACTUALIZAR, así que dejarla pasar rompería un agente que hoy sí // reporta. Es más barato corregir el .env del panel que reparar el del servidor. - const originMismatch = panelUrlOriginMismatch(); - if (originMismatch) { - return fail(500, { error: originMismatch }); + const urlProblem = panelUrlProblem(); + if (urlProblem) { + return fail(500, { error: urlProblem }); + } + + // Instalación LIMPIA en otra ruta. Sin esto no se puede mover una instalación existente + // —por ejemplo al home, para instalar sin privilegios—: el formulario pide `update` en + // cuanto hay versión instalada, y una actualización exige que en la ruta destino ya viva + // algo (assertExistingInstall). El ack lleva la RUTA confirmada, no un booleano, así que + // una casilla marcada deja de valer si después se cambia el destino. + const resolvedPath = effectiveInstallPath(target.reported_install_path, target.platform); + const cleanInstall = + requestedMode === 'update' && + isCleanInstallConfirmed(resolvedPath, data.get('clean_install_ack')?.toString()); + const mode = cleanInstall ? 'install' : requestedMode; + if (cleanInstall) { + logger.warn({ + message: 'Instalación limpia confirmada por el operador sobre un servidor con versión previa', + context: { + target: target.name, + install_path: resolvedPath, + previous_version: target.installed_version, + confirmed_by: currentUser.username + } + }); } try { diff --git a/src/routes/versiones-cras/+page.svelte b/src/routes/versiones-cras/+page.svelte index d7a872a..d22b4c5 100644 --- a/src/routes/versiones-cras/+page.svelte +++ b/src/routes/versiones-cras/+page.svelte @@ -56,6 +56,20 @@ let installing = $state(false); /** Confirmación explícita de plataforma cuando el panel no la pudo determinar. */ let platformAck = $state(false); + /** Confirmación de instalar LIMPIO en la ruta elegida, sobre un servidor que ya tiene versión. */ + let cleanInstallAck = $state(false); + + /** + * Ruta donde instalará el panel: la misma que resuelve el servidor con effectiveInstallPath. + * Se muestra en vez del valor crudo porque lo que el operador necesita confirmar es dónde va + * a caer la instalación, no si la columna está en null. + */ + let targetInstallPath = $derived( + installTarget + ? (effectiveInstallPath(installTarget.reported_install_path, installTarget.platform) ?? + '(sin determinar)') + : '' + ); let installOptions = $derived( installTarget ? releasesFor(installTarget.platform) : [] @@ -76,6 +90,7 @@ installTarget = target; installAutostart = 'service'; platformAck = false; + cleanInstallAck = false; // Se preselecciona SOLO si el panel sabe la plataforma del servidor. Sin plataforma no se // adivina: `releasesFor(null)` es TODO el catálogo, ordenado por descubrimiento, así que // el fallback anterior proponía el artefacto más reciente de cualquier plataforma — un @@ -1144,6 +1159,13 @@ name="mode" value={target.installed_version ? 'update' : 'install'} /> + + {/if} + + + {#if target.installed_version} +
+

+ warning_amber + ¿Instalar limpio en otra ruta? +

+

+ Actualizar exige que en {targetInstallPath} ya viva una + instalación. Si vas a mover el agente a otra carpeta —por ejemplo al + home, para instalar sin privilegios— marca esto: se instalará como + nuevo en esa ruta. +

+ + {#if cleanInstallAck} +

+ Ojo: el agente anterior sigue corriendo. Los dos + reportarían con el mismo nombre de instancia y se pisarían la + carpeta de entrada registrada, que es por donde el panel enruta + los respaldos. Detén el viejo en el servidor: + sudo systemctl disable --now cloudrestoreas +

+ {/if} +
+ {/if}