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 84a4c5e7e0
commit 205566e1ee
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';

View File

@@ -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 {

View File

@@ -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<CrasRelease[]>(
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'}
/>
<!-- Viaja la RUTA confirmada, no un booleano: si el operador marca la casilla y
luego cambia el destino, el ack deja de coincidir y el servidor lo rechaza. -->
<input
type="hidden"
name="clean_install_ack"
value={cleanInstallAck ? targetInstallPath : ''}
/>
<!-- Viaja al servidor como red de seguridad: sin esto un formulario viejo o un POST
a mano seguirían pudiendo mandar el artefacto de la plataforma equivocada. -->
<input
@@ -1211,6 +1233,45 @@
</label>
</div>
{/if}
<!-- Instalación limpia en otra ruta. Solo tiene sentido si el servidor ya
tiene una versión: sin eso el modo ya es `install` y no hay nada que
confirmar. -->
{#if target.installed_version}
<div class="mt-2 rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900">
<p class="font-medium">
<span class="material-icons-outlined align-middle text-base">warning_amber</span>
¿Instalar limpio en otra ruta?
</p>
<p class="mt-1">
Actualizar exige que en <code>{targetInstallPath}</code> 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.
</p>
<label class="mt-2 flex items-start gap-2">
<input
type="checkbox"
bind:checked={cleanInstallAck}
class="mt-0.5 rounded border-amber-400"
/>
<span>
Instalar limpio en <strong>{targetInstallPath}</strong>. La
instalación anterior de {target.name}
({target.installed_version}) <strong>no se toca</strong>.
</span>
</label>
{#if cleanInstallAck}
<p class="mt-2 border-t border-amber-300 pt-2">
<strong>Ojo:</strong> 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:
<code class="mt-1 block">sudo systemctl disable --now cloudrestoreas</code>
</p>
{/if}
</div>
{/if}
</div>
<div>