feature/cras-instalacion-sin-privilegios #21

Closed
hreyes wants to merge 7 commits from feature/cras-instalacion-sin-privilegios into development
4 changed files with 259 additions and 15 deletions
Showing only changes of commit c433e1c34e - Show all commits

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>