fix(versiones-cras): validar que la URL sembrada sea alcanzable y permitir instalar limpio

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>
This commit is contained in:
2026-07-30 15:03:35 -06:00
parent dcba1e0a87
commit c433e1c34e
4 changed files with 259 additions and 15 deletions

View File

@@ -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);
});
});

View File

@@ -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';