fix(versiones-cras): permitir fijar la ruta de instalación y blindar la config de Gitea

Un agente anterior a 1.1.0 no reporta su install_path, así que el panel caía al
default de plataforma y ACTUALIZAR abortaba cuando la instalación vivía en otra
carpeta (p. ej. C:\Aduanasoft\CloudRestoreAS-win). Era un huevo-y-gallina: la
ruta se empieza a reportar en 1.1.x, que es justo lo que no se podía instalar.

Se agrega la captura manual de la ruta por servidor. No hace falta esquema nuevo
ni lógica de resolución nueva: el upsert del estado ya usa
install_path = COALESCE(EXCLUDED.install_path, actual), así que el valor
capturado sobrevive los reportes sin ruta del agente viejo, y resolveInstallPath
y cras-verify ya leen esa misma columna. Cuando el servidor quede en 1.1.x su
propio reporte lo sustituye por la ruta real.

De paso, dos fallos de configuración que costaron el diagnóstico:

- GITEA_TOKEN con los `<>` de la plantilla se veía como un 401 opaco de Gitea,
  idéntico al de un token revocado. Se valida la forma antes de llamar, el
  aviso sale al cargar la pantalla y 401 y 403 dejan de colapsar al mismo
  texto. Un rechazo ahora deja línea en los logs: no dejaba ninguna.
- PANEL_PUBLIC_URL sin el puerto apuntaba a otro servicio. Esa URL se siembra
  en el config/.env del destino también al ACTUALIZAR, así que rompería un
  agente que ya reportaba: se avisa y se aborta antes de tocar el servidor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 12:16:06 -06:00
parent 14b611c581
commit d570c7dac2
8 changed files with 541 additions and 18 deletions

View File

@@ -80,6 +80,11 @@ CRAS_PACKAGE_NAME=cloudrestoreas
# PAT de Gitea. El panel solo LEE el registro, así que basta el scope `read:package`. # PAT de Gitea. El panel solo LEE el registro, así que basta el scope `read:package`.
# (El token con `write:package` vive en la máquina de build, no aquí.) # (El token con `write:package` vive en la máquina de build, no aquí.)
# Sin este token /versiones-cras carga pero avisa que no puede sincronizar. # Sin este token /versiones-cras carga pero avisa que no puede sincronizar.
#
# El valor va DESNUDO: sin los `<>` de una plantilla, sin comillas y sin espacios. Compose los
# pasa literales y Gitea responde 401, indistinguible de un token revocado.
# Y tras cambiarlo hay que RECREAR el contenedor (`docker compose up -d`), no reiniciarlo:
# `environment:` con `${VAR:-}` congela el valor al crearlo y `restart` no relee este archivo.
GITEA_TOKEN= GITEA_TOKEN=
# Caché local de artefactos. Cada uno pesa ~270 MB y se publican dos por versión, así que la # Caché local de artefactos. Cada uno pesa ~270 MB y se publican dos por versión, así que la
@@ -88,7 +93,19 @@ GITEA_TOKEN=
CRAS_RELEASES_DIR=./local-cras-releases CRAS_RELEASES_DIR=./local-cras-releases
CRAS_CACHE_KEEP_VERSIONS=3 CRAS_CACHE_KEEP_VERSIONS=3
# Origen del bind-mount de esa caché en el HOST (docker-compose.prod.yml). Es una ruta del host,
# no del contenedor, así que debe existir EN EL SISTEMA DEL HOST: en un host Windows
# `C:/Aduanasoft/cras-releases`, en uno Linux `/srv/panel/cras-releases`. Una ruta estilo POSIX
# en un host Windows no falla: Docker la crea dentro de su propia VM, donde nadie la ve ni la
# respalda y consume el disco virtual.
CRAS_RELEASES_HOST_PATH=
# URL con la que el agente instalado reportará al panel. El instalador remoto la siembra en el # URL con la que el agente instalado reportará al panel. El instalador remoto la siembra en el
# config/.env del servidor destino, así que TIENE que ser alcanzable desde esos servidores (no # config/.env del servidor destino, así que TIENE que ser alcanzable desde esos servidores (no
# localhost). Si se omite se usa ORIGIN. # localhost). Si se omite se usa ORIGIN.
#
# DEBE incluir el puerto si el panel no está detrás de un proxy en 443. El panel escucha en 3000,
# y en cpanel-a24 el 443 lo sirve el PHP legado: sin el `:3000` el agente le pediría todo a otro
# servicio y recibiría 404. Se siembra también al ACTUALIZAR, así que una URL mal puesta rompe
# un agente que ya funcionaba. Lo más seguro es dejarla vacía y que tome ORIGIN.
PANEL_PUBLIC_URL= PANEL_PUBLIC_URL=

View File

@@ -6,18 +6,24 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// vi.mock se iza al tope del archivo, así que las factories no pueden capturar variables // vi.mock se iza al tope del archivo, así que las factories no pueden capturar variables
// declaradas después: se usa vi.hoisted, igual que en api/alerts/send/server.test.ts. // declaradas después: se usa vi.hoisted, igual que en api/alerts/send/server.test.ts.
const { clientQuery, clientRelease, connectMock } = vi.hoisted(() => { const { clientQuery, clientRelease, connectMock, poolQuery } = vi.hoisted(() => {
const clientQuery = vi.fn(); const clientQuery = vi.fn();
const clientRelease = vi.fn(); const clientRelease = vi.fn();
return { return {
clientQuery, clientQuery,
clientRelease, clientRelease,
poolQuery: vi.fn(),
connectMock: vi.fn(async () => ({ query: clientQuery, release: clientRelease })) connectMock: vi.fn(async () => ({ query: clientQuery, release: clientRelease }))
}; };
}); });
vi.mock('$lib/server/db', () => ({ pgPool: { query: vi.fn(), connect: connectMock } })); vi.mock('$lib/server/db', () => ({ pgPool: { query: poolQuery, connect: connectMock } }));
import { matchNodeRowFromBackupStem, deleteRestoreJobLogs } from './controldesk-pg'; import {
matchNodeRowFromBackupStem,
deleteRestoreJobLogs,
setCloudRestoreInstallPath,
upsertCloudRestoreStatus
} from './controldesk-pg';
const NODES = [ const NODES = [
{ {
@@ -140,3 +146,87 @@ describe('deleteRestoreJobLogs', () => {
expect(clientRelease).toHaveBeenCalledOnce(); expect(clientRelease).toHaveBeenCalledOnce();
}); });
}); });
/**
* Ruta de instalación de una instancia de CloudRestoreAS.
*
* Es de lo que depende que ACTUALIZAR encuentre el config/.env del destino en lugar de abortar.
* Un agente anterior a 1.1.0 no reporta su ruta, así que la captura del operador es el único
* camino, y tiene que sobrevivir a los reportes posteriores de ese agente viejo.
*/
describe('ruta de instalación de CloudRestoreAS', () => {
/** Devuelve solo los UPDATE/INSERT: ensure*Table dispara DDL que aquí no interesa. */
function writeStatements(): string[] {
return poolQuery.mock.calls
.map((c) => String(c[0]))
.filter((sql) => /^\s*(UPDATE|INSERT)/i.test(sql));
}
beforeEach(() => {
poolQuery.mockReset();
poolQuery.mockResolvedValue({ rows: [], rowCount: 1 });
});
it('compara el instance_key con LOWER(TRIM(...)), igual que el join con el servidor', async () => {
// listCrasTargetInventory cruza instancia y servidor con
// LOWER(TRIM(instance_key)) = LOWER(TRIM(name)). Con una igualdad exacta aquí, un
// instance_key que difiera en la caja actualizaría 0 filas aunque la pantalla sí los
// hubiera cruzado, y el operador vería "no hay reporte" sobre una fila que existe.
await setCloudRestoreInstallPath('Principal', 'C:\\Aduanasoft\\CloudRestoreAS-win');
const [sql] = writeStatements();
expect(sql).toMatch(/LOWER\(TRIM\(instance_key\)\)\s*=\s*LOWER\(TRIM\(\$2\)\)/i);
});
it('pasa la ruta como parámetro, nunca interpolada en el SQL', async () => {
const path = 'C:\\Aduanasoft\\CloudRestoreAS-win';
await setCloudRestoreInstallPath('Principal', path);
const [sql] = writeStatements();
expect(sql).not.toContain(path);
expect(poolQuery.mock.calls.at(-1)?.[1]).toEqual([path, 'Principal']);
});
it('no mueve reported_at: esa columna dice cuándo reportó el AGENTE', async () => {
await setCloudRestoreInstallPath('Principal', '/opt/cloudrestoreas');
expect(writeStatements()[0]).not.toMatch(/reported_at/i);
});
it('devuelve false si no existe esa instancia, para no fingir que se guardó', async () => {
poolQuery.mockResolvedValue({ rows: [], rowCount: 0 });
expect(await setCloudRestoreInstallPath('Inexistente', '/opt/cloudrestoreas')).toBe(false);
});
it('devuelve true cuando actualizó la fila', async () => {
expect(await setCloudRestoreInstallPath('Principal', '/opt/cloudrestoreas')).toBe(true);
});
/**
* El COALESCE del upsert es la pieza que sostiene todo el arreglo: el agente 1.0.0 reporta
* cada ciclo con install_path en null, y si eso pisara la columna, la ruta capturada duraría
* hasta el siguiente reporte y ACTUALIZAR volvería a abortar sin explicación.
*/
it('un reporte sin ruta NO borra la que ya estaba guardada', async () => {
await upsertCloudRestoreStatus({
inputFolder: '/srv/entrada',
hostName: 'srvmid',
appVersion: '1.0.0',
installPath: null,
instanceKey: 'Principal'
});
const [sql] = writeStatements();
expect(sql).toMatch(/install_path\s*=\s*COALESCE\(EXCLUDED\.install_path/i);
expect(sql).not.toMatch(/install_path\s*=\s*EXCLUDED\.install_path\s*,/i);
});
it('un reporte CON ruta sí la sustituye', async () => {
await upsertCloudRestoreStatus({
inputFolder: '/srv/entrada',
hostName: 'srvmid',
appVersion: '1.1.1',
installPath: '/opt/cloudrestoreas',
instanceKey: 'Principal'
});
// Con EXCLUDED no nulo, el COALESCE resuelve al valor reportado: lo que se comprueba es
// que la ruta viaje como parámetro hasta el upsert y no se pierda en el camino.
expect(poolQuery.mock.calls.at(-1)?.[1]).toContain('/opt/cloudrestoreas');
});
});

View File

@@ -1111,6 +1111,42 @@ export async function upsertCloudRestoreStatus(row: {
); );
} }
/**
* Fija a mano la ruta de instalación de una instancia. Devuelve false si no existe esa fila.
*
* Un agente anterior a 1.1.0 no reporta install_path, así que el panel cae al default de
* plataforma y una ACTUALIZACIÓN aborta cuando la instalación vive en otra carpeta. Capturarla
* es la única forma de romper ese huevo-y-gallina: para que el panel sepa la ruta el agente
* tendría que estar en 1.1.x, y para llegar a 1.1.x hay que poder actualizarlo.
*
* No reusa upsertCloudRestoreStatus porque ese exige input_folder/processed_folder y los pisaría
* con lo que trajera el formulario. Y solo actualiza filas existentes: sin un reporte previo no
* hay instance_key al que colgar el valor, y un INSERT tendría que inventar el resto de columnas.
*
* El COALESCE de ese mismo upsert es lo que hace que este valor sobreviva a los reportes sin ruta
* del agente viejo; cuando el servidor quede en 1.1.x, su reporte lo sustituye por la ruta real.
*
* reported_at NO se toca: significa cuándo reportó el agente, y moverlo por una captura del
* operador falsearía la columna "Último reporte" de la pantalla.
*/
export async function setCloudRestoreInstallPath(
instanceKey: string,
installPath: string
): Promise<boolean> {
await ensureCloudRestoreStatusTable();
// Se compara con LOWER(TRIM(...)) igual que el join instancia↔servidor de
// listCrasTargetInventory: con una igualdad exacta, un instance_key que difiera del nombre
// del servidor en la caja o en espacios actualizaría 0 filas aunque la pantalla sí los
// hubiera cruzado, y el operador vería "no hay reporte" sobre una fila que sí existe.
const r = await pgPool.query(
`UPDATE ${qCloudRestoreStatus()}
SET install_path = $1
WHERE LOWER(TRIM(instance_key)) = LOWER(TRIM($2))`,
[installPath, instanceKey]
);
return (r.rowCount ?? 0) > 0;
}
/** Inserta un registro de bitácora reportado por CloudRestoreAS. */ /** Inserta un registro de bitácora reportado por CloudRestoreAS. */
export async function insertRestoreJobLog(row: { export async function insertRestoreJobLog(row: {
filename: string; filename: string;

View File

@@ -423,7 +423,9 @@ async function resolveInstallPath(
// Esa comprobación se hace con la sesión abierta, en assertExistingInstall(). // Esa comprobación se hace con la sesión abierta, en assertExistingInstall().
if (request.mode === 'update' && !reportedInstallPath) { if (request.mode === 'update' && !reportedInstallPath) {
logger.warn({ logger.warn({
message: 'Actualización sin ruta reportada por el agente; se usará el default', message:
'Actualización sin ruta conocida (el agente no la reporta y no se capturó a mano); ' +
'se usará el default',
context: { target: target.name, install_path: resolved } context: { target: target.name, install_path: resolved }
}); });
} }
@@ -455,7 +457,9 @@ async function assertExistingInstall(
`Se pidió ACTUALIZAR pero en ${installPath} no hay una instalación (falta config/.env). ` + `Se pidió ACTUALIZAR pero en ${installPath} no hay una instalación (falta config/.env). ` +
(reportedPath (reportedPath
? `El agente reportó ${reportedPath}. ` ? `El agente reportó ${reportedPath}. `
: 'El agente no ha reportado su ruta. ') + : 'El agente no ha reportado su ruta —solo lo hace desde 1.1.0—, así que se usó la ' +
'de omisión. Si la instalación vive en otra carpeta, captúrala con el lápiz que ' +
'está junto al nombre del servidor y vuelve a intentar. ') +
'Se aborta para no crear una segunda instalación con la configuración por omisión ' + 'Se aborta para no crear una segunda instalación con la configuración por omisión ' +
'y dejar huérfano el .env personalizado. Si es una instalación nueva, usa Instalar.' 'y dejar huérfano el .env personalizado. Si es una instalación nueva, usa Instalar.'
); );

View File

@@ -9,8 +9,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
// El logger se stubea para poder afirmar que un rechazo de Gitea SÍ deja rastro: sin esa línea,
// diagnosticar el 401 de producción exigió reproducirlo con curl a mano.
const { loggerMock } = vi.hoisted(() => ({
loggerMock: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }
}));
vi.mock('./logger', () => ({ logger: loggerMock }));
import { import {
GiteaError, GiteaError,
giteaTokenProblem,
isGiteaConfigured, isGiteaConfigured,
listPackageFiles, listPackageFiles,
listPackageVersions, listPackageVersions,
@@ -77,6 +85,64 @@ describe('configuración', () => {
}); });
}); });
/**
* Forma del token. En producción el `.env` traía el valor entre `<` y `>` —los signos de una
* plantilla que nadie quitó—; Compose los pasa literales, el panel mandaba
* `Authorization: token <abc...>` y Gitea respondía 401, indistinguible de un token revocado.
*/
describe('giteaTokenProblem', () => {
it('acepta un token limpio', () => {
expect(giteaTokenProblem()).toBeNull();
});
it('detecta los signos < > de la plantilla', () => {
env.GITEA_TOKEN = '<958ce0c4db948c805aaee19dcfb66f0daa563518>';
expect(giteaTokenProblem()).toMatch(/< >|plantilla/);
});
it('detecta comillas envolventes', () => {
env.GITEA_TOKEN = '"958ce0c4db948c805aaee19dcfb66f0daa563518"';
expect(giteaTokenProblem()).toMatch(/comillas/i);
env.GITEA_TOKEN = "'958ce0c4db948c805aaee19dcfb66f0daa563518'";
expect(giteaTokenProblem()).toMatch(/comillas/i);
});
it('detecta un espacio en medio, que es un valor partido', () => {
env.GITEA_TOKEN = '958ce0c4db94 8c805aaee19dcfb66f0daa563518';
expect(giteaTokenProblem()).toMatch(/espacios/i);
});
it('reporta el token ausente, que es el caso del compose con la variable vacía', () => {
env.GITEA_TOKEN = '';
expect(giteaTokenProblem()).toMatch(/no está configurado/i);
});
it('tolera los espacios de los extremos y el CRLF de un .env de Windows', () => {
// El .trim() de authHeaders los absorbe, y romper aquí un token bueno sería peor que el
// fallo que este validador previene.
env.GITEA_TOKEN = ' 958ce0c4db948c805aaee19dcfb66f0daa563518\r\n';
expect(giteaTokenProblem()).toBeNull();
});
it('NO valida largo ni alfabeto: si Gitea cambia el formato del PAT, no debe romperse', () => {
env.GITEA_TOKEN = 'gto_unTokenDeOtroFormato.v2';
expect(giteaTokenProblem()).toBeNull();
});
it('un token mal capturado no llega a Gitea', async () => {
env.GITEA_TOKEN = '<958ce0c4db948c805aaee19dcfb66f0daa563518>';
const spy = stubFetch(() => jsonResponse([]));
await expect(listPackageVersions()).rejects.toMatchObject({ status: 500 });
expect(spy).not.toHaveBeenCalled();
});
it('el motivo nunca incluye el valor del token: se muestra en pantalla y en logs', () => {
const token = '<958ce0c4db948c805aaee19dcfb66f0daa563518>';
env.GITEA_TOKEN = token;
expect(giteaTokenProblem()).not.toContain('958ce0c4');
});
});
describe('listPackageVersions', () => { describe('listPackageVersions', () => {
it('manda el token y devuelve solo las versiones del paquete exacto', async () => { it('manda el token y devuelve solo las versiones del paquete exacto', async () => {
const spy = stubFetch((url) => { const spy = stubFetch((url) => {
@@ -108,9 +174,33 @@ describe('listPackageVersions', () => {
expect((await listPackageVersions()).map((v) => v.version)).toEqual(['1.1.0']); expect((await listPackageVersions()).map((v) => v.version)).toEqual(['1.1.0']);
}); });
it('401/403 explican que el token fue rechazado', async () => { it('401 habla del VALOR del token y menciona los signos de la plantilla', async () => {
stubFetch(() => new Response('nope', { status: 401 })); stubFetch(() => new Response('nope', { status: 401 }));
await expect(listPackageVersions()).rejects.toThrow(/token/i); await expect(listPackageVersions()).rejects.toThrow(/< >|revocado/);
});
/**
* 401 y 403 colapsaban al mismo texto, y son problemas distintos: uno es el valor del token,
* el otro sus permisos. Con el mensaje único no había forma de saber cuál de los dos revisar.
*/
it('403 habla del SCOPE y del paquete, no del valor del token', async () => {
stubFetch(() => new Response('nope', { status: 403 }));
await expect(listPackageVersions()).rejects.toThrow(/read:package/);
stubFetch(() => new Response('nope', { status: 403 }));
await expect(listPackageVersions()).rejects.toThrow(/ADUANASOFT/);
});
it('un rechazo deja rastro en los logs, con el status y sin el token', async () => {
loggerMock.error.mockClear();
stubFetch(() => new Response('nope', { status: 403 }));
await expect(listPackageVersions()).rejects.toThrow(GiteaError);
expect(loggerMock.error).toHaveBeenCalledOnce();
const entry = loggerMock.error.mock.calls[0][0] as {
context: { status: number; url: string };
};
expect(entry.context.status).toBe(403);
expect(JSON.stringify(entry)).not.toContain('token-de-prueba');
}); });
it('un error de red se traduce a 503, no a una excepción cruda', async () => { it('un error de red se traduce a 503, no a una excepción cruda', async () => {

View File

@@ -60,11 +60,43 @@ export function isGiteaConfigured(): boolean {
return Boolean(env.GITEA_TOKEN && env.GITEA_TOKEN.trim()); return Boolean(env.GITEA_TOKEN && env.GITEA_TOKEN.trim());
} }
function authHeaders(): Record<string, string> { /**
* Motivo por el que el GITEA_TOKEN configurado no se puede usar, o null si se puede intentar.
*
* Existe porque un token mal capturado se veía en la UI como un 401 opaco de Gitea, idéntico al
* de un token revocado: en producción el `.env` traía el valor entre `<` y `>` —los signos de una
* plantilla que nadie quitó— y Compose los pasa literales, así que el panel mandaba
* `Authorization: token <abc...>` y no había forma de distinguirlo de un problema de permisos.
*
* Solo se rechaza lo que es inequívocamente un error de captura. El largo y el alfabeto NO se
* validan a propósito: si Gitea cambia el formato de sus PAT, un validador rígido rompería un
* token bueno, y ese fallo sería peor que el que esto previene.
*
* El motivo se muestra en pantalla y se escribe en los logs, así que NUNCA incluye el valor.
*/
export function giteaTokenProblem(): string | null {
const token = (env.GITEA_TOKEN || '').trim(); const token = (env.GITEA_TOKEN || '').trim();
if (!token) { if (!token) return 'GITEA_TOKEN no está configurado en el panel';
throw new GiteaError(500, 'GITEA_TOKEN no configurado en el panel'); if (/[<>]/.test(token)) {
return 'GITEA_TOKEN trae los signos < > de la plantilla; el valor va desnudo, sin ellos';
} }
if (/["'`]/.test(token)) {
return 'GITEA_TOKEN trae comillas; el valor va sin ellas';
}
// El .trim() de authHeaders ya absorbe los espacios de los extremos y el CRLF de un .env de
// Windows; un espacio en medio no es eso, es un valor partido o dos valores pegados.
if (/\s/.test(token)) {
return 'GITEA_TOKEN trae espacios en medio del valor';
}
return null;
}
function authHeaders(): Record<string, string> {
const problem = giteaTokenProblem();
if (problem) {
throw new GiteaError(500, problem);
}
const token = (env.GITEA_TOKEN || '').trim();
return { Authorization: `token ${token}`, Accept: 'application/json' }; return { Authorization: `token ${token}`, Accept: 'application/json' };
} }
@@ -86,10 +118,27 @@ async function getJson<T>(url: string): Promise<T> {
throw new GiteaError(503, `No se pudo contactar a Gitea: ${message}`); throw new GiteaError(503, `No se pudo contactar a Gitea: ${message}`);
} }
// Se loguea porque hasta ahora un sync rechazado no dejaba NI UNA línea en los logs del
// contenedor: diagnosticar el 401 de producción exigió reproducirlo con curl a mano.
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
logger.error({
message: 'Gitea rechazó la petición del panel',
context: {
status: response.status,
url,
owner: owner(),
package: packageName()
}
});
// 401 y 403 colapsaban al mismo texto, y son problemas distintos: uno es el valor del
// token, el otro sus permisos. Con el mensaje único no había forma de saber cuál revisar.
throw new GiteaError( throw new GiteaError(
response.status, response.status,
'Gitea rechazó el token del panel (revisa GITEA_TOKEN y su scope read:package)' response.status === 401
? 'Gitea no reconoce el token del panel: revisa que GITEA_TOKEN no traiga < >, ' +
'comillas ni espacios, y que no esté revocado'
: 'El token del panel es válido pero no tiene permiso: le falta el scope ' +
`read:package o no puede leer los paquetes de ${owner()} (${packageLocation()})`
); );
} }
if (response.status === 404) { if (response.status === 404) {

View File

@@ -34,9 +34,20 @@ import {
ArtifactError, ArtifactError,
type PrunePlan type PrunePlan
} from '$lib/server/cras-artifacts'; } from '$lib/server/cras-artifacts';
import { installPlatformVerdict, platformLabel } from '$lib/cras-version'; import {
installPlatformVerdict,
isInsideWorkFolder,
isSafeInstallPath,
platformLabel
} from '$lib/cras-version';
import { setCloudRestoreInstallPath } from '$lib/server/controldesk-pg';
import { syncCrasReleasesFromGitea } from '$lib/server/cras-sync'; import { syncCrasReleasesFromGitea } from '$lib/server/cras-sync';
import { GiteaError, isGiteaConfigured, packageLocation } from '$lib/server/gitea-packages'; import {
GiteaError,
giteaTokenProblem,
isGiteaConfigured,
packageLocation
} from '$lib/server/gitea-packages';
import { installCrasOnTarget, InstallError } from '$lib/server/cras-install'; import { installCrasOnTarget, InstallError } from '$lib/server/cras-install';
import { logger } from '$lib/server/logger'; import { logger } from '$lib/server/logger';
@@ -64,6 +75,41 @@ function panelApiToken(): string {
return (env.CLOUDRESTORE_API_TOKEN || '').trim(); return (env.CLOUDRESTORE_API_TOKEN || '').trim();
} }
/**
* Aviso cuando PANEL_PUBLIC_URL apunta a un origen distinto del que sirve el panel.
*
* Esta URL se siembra en el config/.env del agente, 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.
*/
function panelUrlOriginMismatch(): string | null {
const declared = (env.PANEL_PUBLIC_URL || '').trim();
const origin = (env.ORIGIN || '').trim();
if (!declared || !origin) return null;
let declaredUrl: URL;
let originUrl: URL;
try {
declaredUrl = new URL(declared);
originUrl = new URL(origin);
} catch {
return `PANEL_PUBLIC_URL no es una URL válida (${declared}): el agente no sabría a dónde reportar.`;
}
// `host` incluye el puerto, que es justo lo que se omitió en producción.
if (declaredUrl.host === originUrl.host) return null;
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.'
);
}
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
const currentUser = await requireAdmin(cookies); const currentUser = await requireAdmin(cookies);
let dbWarning: string | null = null; let dbWarning: string | null = null;
@@ -137,10 +183,11 @@ export const load: PageServerLoad = async ({ cookies }) => {
// Avisos de configuración: sin estos datos la pantalla se ve bien pero no puede operar. // Avisos de configuración: sin estos datos la pantalla se ve bien pero no puede operar.
const configWarnings: string[] = []; const configWarnings: string[] = [];
if (!isGiteaConfigured()) { // Cubre el token ausente y el mal capturado. Antes solo se avisaba del ausente, así que un
configWarnings.push( // valor con los `<>` de la plantilla solo se manifestaba como un 401 al presionar Sincronizar.
'GITEA_TOKEN no está configurado en el panel: no se puede sincronizar ni descargar versiones.' const tokenProblem = giteaTokenProblem();
); if (tokenProblem) {
configWarnings.push(`${tokenProblem}: no se puede sincronizar ni descargar versiones.`);
} }
if (!panelApiToken()) { if (!panelApiToken()) {
configWarnings.push( configWarnings.push(
@@ -152,6 +199,10 @@ export const load: PageServerLoad = async ({ cookies }) => {
'PANEL_PUBLIC_URL (u ORIGIN) no está configurado: el agente no sabría a qué URL reportar.' '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);
}
return { return {
currentUser, currentUser,
@@ -326,6 +377,104 @@ export const actions: Actions = {
} }
}, },
/**
* Fija a mano la ruta de instalación de un servidor.
*
* Un agente anterior a 1.1.0 no reporta su install_path, así que el panel cae al default de
* plataforma y ACTUALIZAR aborta cuando la instalación vive en otra carpeta. Sin esta captura
* ese servidor no se podría actualizar nunca: la ruta empieza a reportarse en 1.1.x, que es
* justo lo que no se puede instalar. En cuanto el servidor quede en 1.1.x su propio reporte
* sustituye este valor por la ruta real.
*/
setInstallPath: async ({ cookies, request }) => {
const currentUser = await requireAdmin(cookies);
const data = await request.formData();
const targetId = parseId(data, 'target_id');
const rawPath = data.get('install_path')?.toString().trim() ?? '';
if (!targetId) return fail(400, { error: 'Servidor inválido.' });
if (!rawPath) return fail(400, { error: 'Captura la ruta de instalación.' });
let target: Awaited<ReturnType<typeof listCrasTargetInventory>>[number] | undefined;
try {
target = (await listCrasTargetInventory()).find(
(t) => t.restore_target_id === targetId
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return fail(500, { error: `No se pudo leer el inventario de servidores: ${msg}` });
}
if (!target) return fail(404, { error: 'El servidor de restauración ya no existe.' });
// Sin plataforma no hay con qué validar: las reglas de ruta son distintas en Windows y
// en Linux, y aplicar las equivocadas dejaría pasar una ruta que el instalador no puede
// usar.
if (!target.platform) {
return fail(422, {
error:
`No se conoce la plataforma de ${target.name}, y sin ella no se puede validar ` +
'la ruta. Captura el campo SO en Servidores de Restauración.'
});
}
if (!isSafeInstallPath(rawPath, target.platform)) {
return fail(422, {
error:
`"${rawPath}" no es una ruta válida para ${platformLabel(target.platform)}. ` +
'Debe ser absoluta, sin comillas ni metacaracteres, sin ".." y sin rutas de red.'
});
}
// Se normaliza igual que effectiveInstallPath (cras-version.ts) para que el valor guardado
// sea comparable con el que reporte el agente después y no queden dos formas del mismo path.
const installPath = rawPath.replace(/[/\\]+$/, '');
// La raíz de una unidad pasa isSafeInstallPath pero no es un destino de instalación: ahí
// el instalador aplicaría ACLs y borrados recursivos sobre todo el disco. Solo se puede
// llegar por captura manual, porque el agente reporta la carpeta de su ejecutable.
if (!installPath || /^[A-Za-z]:$/.test(installPath)) {
return fail(422, {
error: 'La ruta no puede ser la raíz de la unidad; usa una carpeta.'
});
}
if (isInsideWorkFolder(installPath, [target.input_folder, target.processed_folder])) {
return fail(409, {
error:
`${installPath} está dentro de una carpeta de trabajo de ${target.name}. ` +
'Ahí el agente tomaría su propio binario por un respaldo a procesar.'
});
}
try {
const updated = await setCloudRestoreInstallPath(target.name, installPath);
if (!updated) {
return fail(409, {
error:
`${target.name} no tiene ningún reporte del agente todavía, así que no hay ` +
'instancia a la que fijarle la ruta. Espera a que reporte por primera vez.'
});
}
logger.info({
message: 'Ruta de instalación de CRAS fijada a mano',
context: {
target: target.name,
install_path: installPath,
previous: target.reported_install_path,
set_by: currentUser.username
}
});
return {
success:
`Ruta de ${target.name} fijada en ${installPath}. ` +
'Verifica antes de actualizar; cuando el servidor quede en 1.1.x el agente ' +
'reportará su ruta y este valor se corrige solo.'
};
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return fail(500, { error: `No se pudo guardar la ruta: ${msg}` });
}
},
/** /**
* Instala o actualiza CRAS en un servidor. Bloqueante: puede tardar varios minutos * Instala o actualiza CRAS en un servidor. Bloqueante: puede tardar varios minutos
* subiendo ~270 MB. La UI hace polling de cras_install_runs.steps para el progreso. * subiendo ~270 MB. La UI hace polling de cras_install_runs.steps para el progreso.
@@ -400,6 +549,13 @@ export const actions: Actions = {
'sin ellos el agente instalado no sabría a dónde reportar.' 'sin ellos el agente instalado no sabría a dónde reportar.'
}); });
} }
// 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 });
}
try { try {
const outcome = await installCrasOnTarget({ const outcome = await installCrasOnTarget({

View File

@@ -5,7 +5,7 @@
import type { SubmitFunction } from '@sveltejs/kit'; import type { SubmitFunction } from '@sveltejs/kit';
import AppShell from '$lib/components/AppShell.svelte'; import AppShell from '$lib/components/AppShell.svelte';
import Spinner from '$lib/components/Spinner.svelte'; import Spinner from '$lib/components/Spinner.svelte';
import { platformLabel } from '$lib/cras-version'; import { effectiveInstallPath, platformLabel } from '$lib/cras-version';
import { import {
INSTALL_STEP_LABEL, INSTALL_STEP_LABEL,
currentPhaseLabel, currentPhaseLabel,
@@ -308,6 +308,14 @@
}; };
} }
/**
* Servidor cuya ruta de instalación se está capturando. Se muestra la ruta *efectiva* —la
* misma que resuelve el servidor con effectiveInstallPath— y no el valor crudo, porque lo que
* importa saber antes de actualizar es dónde va a buscar el instalador, no si la columna está
* en null.
*/
let editingPathTargetId = $state<number | null>(null);
/** /**
* `target.running_install_id` viene del load y no se refresca hasta el invalidateAll final, * `target.running_install_id` viene del load y no se refresca hasta el invalidateAll final,
* así que la fila seguía ofreciendo Verificar/Instalar durante toda la instalación y se podía * así que la fila seguía ofreciendo Verificar/Instalar durante toda la instalación y se podía
@@ -723,6 +731,79 @@
{target.ssh_username}@{target.ssh_host} {target.ssh_username}@{target.ssh_host}
</div> </div>
{/if} {/if}
<!-- Ruta de instalación: es lo que decide si ACTUALIZAR encuentra
el config/.env o aborta. Un agente anterior a 1.1.0 no la
reporta, así que se puede capturar a mano. -->
{#if editingPathTargetId === target.restore_target_id}
<form
method="POST"
action="?/setInstallPath"
use:enhance={submitAction(
`path-${target.restore_target_id}`,
() => (editingPathTargetId = null)
)}
class="mt-1 flex items-center gap-1"
>
<input type="hidden" name="target_id" value={target.restore_target_id} />
<input
name="install_path"
value={effectiveInstallPath(
target.reported_install_path,
target.platform
) ?? ''}
required
spellcheck="false"
autocomplete="off"
class="w-64 rounded border border-slate-300 px-2 py-1 font-mono text-xs font-normal text-slate-700"
/>
<button
type="submit"
disabled={busyAction === `path-${target.restore_target_id}`}
class="rounded bg-indigo-600 px-2 py-1 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-40"
>
{#if busyAction === `path-${target.restore_target_id}`}
<Spinner size={12} />
{:else}
Guardar
{/if}
</button>
<button
type="button"
onclick={() => (editingPathTargetId = null)}
class="rounded border border-slate-300 px-2 py-1 text-xs font-medium text-slate-600 hover:bg-slate-50"
>
Cancelar
</button>
</form>
{:else}
<div class="mt-1 flex items-center gap-1 text-xs font-normal">
<span class="font-mono text-slate-600">
{effectiveInstallPath(
target.reported_install_path,
target.platform
) ?? '—'}
</span>
{#if target.platform && !target.reported_install_path}
<span
class="text-amber-700"
title="El agente no ha reportado su ruta (lo hace desde 1.1.0). Se usará la de omisión de la plataforma, y ACTUALIZAR aborta si la instalación vive en otra carpeta."
>
(por omisión)
</span>
{/if}
<button
type="button"
onclick={() => (editingPathTargetId = target.restore_target_id)}
disabled={!target.platform}
title={target.platform
? 'Capturar la ruta donde vive la instalación. Necesario para actualizar un agente anterior a 1.1.0 instalado fuera de la ruta por omisión.'
: 'Sin plataforma no se puede validar la ruta; captura el campo SO en Servidores de Restauración'}
class="text-slate-400 hover:text-indigo-600 disabled:cursor-not-allowed disabled:opacity-40"
>
<span class="material-icons-outlined text-sm">edit</span>
</button>
</div>
{/if}
</td> </td>
<td class="px-4 py-3 text-gray-700"> <td class="px-4 py-3 text-gray-700">
{platformLabel(target.platform)} {platformLabel(target.platform)}