feature/cras-instalacion-sin-privilegios #21
17
.env.example
17
.env.example
@@ -80,6 +80,11 @@ CRAS_PACKAGE_NAME=cloudrestoreas
|
||||
# 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í.)
|
||||
# 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=
|
||||
|
||||
# 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_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
|
||||
# config/.env del servidor destino, así que TIENE que ser alcanzable desde esos servidores (no
|
||||
# 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=
|
||||
|
||||
@@ -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
|
||||
// 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 clientRelease = vi.fn();
|
||||
return {
|
||||
clientQuery,
|
||||
clientRelease,
|
||||
poolQuery: vi.fn(),
|
||||
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 = [
|
||||
{
|
||||
@@ -140,3 +146,87 @@ describe('deleteRestoreJobLogs', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
export async function insertRestoreJobLog(row: {
|
||||
filename: string;
|
||||
|
||||
@@ -423,7 +423,9 @@ async function resolveInstallPath(
|
||||
// Esa comprobación se hace con la sesión abierta, en assertExistingInstall().
|
||||
if (request.mode === 'update' && !reportedInstallPath) {
|
||||
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 }
|
||||
});
|
||||
}
|
||||
@@ -455,7 +457,9 @@ async function assertExistingInstall(
|
||||
`Se pidió ACTUALIZAR pero en ${installPath} no hay una instalación (falta config/.env). ` +
|
||||
(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 ' +
|
||||
'y dejar huérfano el .env personalizado. Si es una instalación nueva, usa Instalar.'
|
||||
);
|
||||
|
||||
@@ -9,8 +9,16 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
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 {
|
||||
GiteaError,
|
||||
giteaTokenProblem,
|
||||
isGiteaConfigured,
|
||||
listPackageFiles,
|
||||
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', () => {
|
||||
it('manda el token y devuelve solo las versiones del paquete exacto', async () => {
|
||||
const spy = stubFetch((url) => {
|
||||
@@ -108,9 +174,33 @@ describe('listPackageVersions', () => {
|
||||
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 }));
|
||||
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 () => {
|
||||
|
||||
@@ -60,11 +60,43 @@ export function isGiteaConfigured(): boolean {
|
||||
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();
|
||||
if (!token) {
|
||||
throw new GiteaError(500, 'GITEA_TOKEN no configurado en el panel');
|
||||
if (!token) return 'GITEA_TOKEN no está 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' };
|
||||
}
|
||||
|
||||
@@ -86,10 +118,27 @@ async function getJson<T>(url: string): Promise<T> {
|
||||
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) {
|
||||
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(
|
||||
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) {
|
||||
|
||||
@@ -34,9 +34,20 @@ import {
|
||||
ArtifactError,
|
||||
type PrunePlan
|
||||
} 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 { 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 { logger } from '$lib/server/logger';
|
||||
|
||||
@@ -64,6 +75,41 @@ function panelApiToken(): string {
|
||||
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 }) => {
|
||||
const currentUser = await requireAdmin(cookies);
|
||||
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.
|
||||
const configWarnings: string[] = [];
|
||||
if (!isGiteaConfigured()) {
|
||||
configWarnings.push(
|
||||
'GITEA_TOKEN no está configurado en el panel: no se puede sincronizar ni descargar versiones.'
|
||||
);
|
||||
// Cubre el token ausente y el mal capturado. Antes solo se avisaba del ausente, así que un
|
||||
// valor con los `<>` de la plantilla solo se manifestaba como un 401 al presionar Sincronizar.
|
||||
const tokenProblem = giteaTokenProblem();
|
||||
if (tokenProblem) {
|
||||
configWarnings.push(`${tokenProblem}: no se puede sincronizar ni descargar versiones.`);
|
||||
}
|
||||
if (!panelApiToken()) {
|
||||
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.'
|
||||
);
|
||||
}
|
||||
const originMismatch = panelUrlOriginMismatch();
|
||||
if (originMismatch) {
|
||||
configWarnings.push(originMismatch);
|
||||
}
|
||||
|
||||
return {
|
||||
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
|
||||
* 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.'
|
||||
});
|
||||
}
|
||||
// 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 {
|
||||
const outcome = await installCrasOnTarget({
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import AppShell from '$lib/components/AppShell.svelte';
|
||||
import Spinner from '$lib/components/Spinner.svelte';
|
||||
import { platformLabel } from '$lib/cras-version';
|
||||
import { effectiveInstallPath, platformLabel } from '$lib/cras-version';
|
||||
import {
|
||||
INSTALL_STEP_LABEL,
|
||||
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,
|
||||
* 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}
|
||||
</div>
|
||||
{/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 class="px-4 py-3 text-gray-700">
|
||||
{platformLabel(target.platform)}
|
||||
|
||||
Reference in New Issue
Block a user