feature/cras-instalacion-sin-privilegios #21
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user