feature/cras-instalacion-sin-privilegios #20
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`.
|
# 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=
|
||||||
|
|||||||
@@ -44,12 +44,19 @@ describe('currentPhaseLabel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('expectedStepCount', () => {
|
describe('expectedStepCount', () => {
|
||||||
it('cuenta verificar-servicio solo en modo servicio', () => {
|
it('cuenta verificar-servicio solo en los modos que dejan algo arrancado', () => {
|
||||||
// `cras-install.ts` corta con `if (autostart !== 'service') return;` antes de ese paso.
|
// `cras-install.ts` corta antes de ese paso cuando el modo no arranca nada.
|
||||||
expect(expectedStepCount('service')).toBe(11);
|
expect(expectedStepCount('service')).toBe(11);
|
||||||
expect(expectedStepCount('desktop')).toBe(10);
|
expect(expectedStepCount('desktop')).toBe(10);
|
||||||
expect(expectedStepCount('none')).toBe(10);
|
expect(expectedStepCount('none')).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('el servicio de usuario también verifica, aunque no use privilegios', () => {
|
||||||
|
// Si esto se quedara comparando solo con 'service', la barra de una instalación sin
|
||||||
|
// privilegios llegaría al 100 % con un paso todavía por delante.
|
||||||
|
expect(expectedStepCount('user-service')).toBe(11);
|
||||||
|
expect(progressPercent(10, 'user-service', 'running')).toBe(91);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('progressPercent', () => {
|
describe('progressPercent', () => {
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ export interface InstallStepView {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AutostartMode = 'service' | 'desktop' | 'none';
|
/**
|
||||||
|
* `user-service` instala bajo el home del usuario con un unit de systemd **de usuario**: no
|
||||||
|
* necesita root ni sudo, así que bastan el usuario y la contraseña SSH que el panel ya guarda.
|
||||||
|
* Los otros modos de servicio escriben en /opt y /etc/systemd/system, que sí exigen privilegios.
|
||||||
|
*/
|
||||||
|
export type AutostartMode = 'service' | 'user-service' | 'desktop' | 'none';
|
||||||
export type RunStatus = 'running' | 'completed' | 'failed';
|
export type RunStatus = 'running' | 'completed' | 'failed';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,7 +92,10 @@ export function currentPhaseLabel(steps: InstallStepView[]): string {
|
|||||||
|
|
||||||
/** Pasos esperados del camino feliz según el modo de arranque. */
|
/** Pasos esperados del camino feliz según el modo de arranque. */
|
||||||
export function expectedStepCount(autostart: AutostartMode): number {
|
export function expectedStepCount(autostart: AutostartMode): number {
|
||||||
return autostart === 'service'
|
// Los dos modos de servicio —de sistema y de usuario— emiten `verificar-servicio`; los que
|
||||||
|
// no dejan nada arrancado, no. Si esto se quedara comparando solo con 'service', la barra de
|
||||||
|
// una instalación en modo usuario llegaría al 100 % con un paso todavía por delante.
|
||||||
|
return autostart === 'service' || autostart === 'user-service'
|
||||||
? INSTALL_STEP_SEQUENCE.length
|
? INSTALL_STEP_SEQUENCE.length
|
||||||
: INSTALL_STEP_SEQUENCE.length - 1;
|
: INSTALL_STEP_SEQUENCE.length - 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
effectivePlatform,
|
effectivePlatform,
|
||||||
installPlatformVerdict,
|
installPlatformVerdict,
|
||||||
isCrasPlatform,
|
isCrasPlatform,
|
||||||
|
isInsideWorkFolder,
|
||||||
isNewer,
|
isNewer,
|
||||||
isValidVersion,
|
isValidVersion,
|
||||||
osToPlatform,
|
osToPlatform,
|
||||||
@@ -201,3 +202,61 @@ describe('installPlatformVerdict', () => {
|
|||||||
expect(installPlatformVerdict(null, 'windows', ' WINDOWS ')).toBe('ok');
|
expect(installPlatformVerdict(null, 'windows', ' WINDOWS ')).toBe('ok');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard de la ruta de instalación contra las carpetas de trabajo del agente.
|
||||||
|
*
|
||||||
|
* No tenía ni una prueba, y con la relación mirada en los dos sentidos rechazaba el layout
|
||||||
|
* NORMAL —`DIR_ENTRADA = APP_DIR / "Entrada"` en constants.py del agente— así que ninguna
|
||||||
|
* instalación ni actualización podía pasar. Se salvaban solo las instalaciones fuera de la ruta
|
||||||
|
* por omisión, por accidente del nombre.
|
||||||
|
*/
|
||||||
|
describe('isInsideWorkFolder', () => {
|
||||||
|
it('acepta el layout normal: las carpetas de trabajo cuelgan de la de instalación', () => {
|
||||||
|
expect(
|
||||||
|
isInsideWorkFolder('/opt/cloudrestoreas', [
|
||||||
|
'/opt/cloudrestoreas/Entrada',
|
||||||
|
'/opt/cloudrestoreas/Procesados'
|
||||||
|
])
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isInsideWorkFolder('C:\\Aduanasoft\\CloudRestoreAS', [
|
||||||
|
'C:\\Aduanasoft\\CloudRestoreAS\\Entrada'
|
||||||
|
])
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rechaza el binario DENTRO de una carpeta de trabajo, que es el peligro real', () => {
|
||||||
|
// Ahí el agente tomaría su propio ejecutable por un respaldo a procesar.
|
||||||
|
expect(isInsideWorkFolder('/srv/entrada/cras', ['/srv/entrada'])).toBe(true);
|
||||||
|
expect(
|
||||||
|
isInsideWorkFolder('D:\\Respaldos\\Entrada\\app', ['D:\\Respaldos\\Entrada'])
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rechaza instalar exactamente EN la carpeta de trabajo', () => {
|
||||||
|
expect(isInsideWorkFolder('/srv/entrada', ['/srv/entrada'])).toBe(true);
|
||||||
|
expect(isInsideWorkFolder('/srv/entrada/', ['/srv/entrada'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normaliza separadores, barra final y caja antes de comparar', () => {
|
||||||
|
expect(isInsideWorkFolder('C:/Respaldos/Entrada/app', ['c:\\respaldos\\entrada\\'])).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un prefijo que no es de carpeta no cuenta como estar dentro', () => {
|
||||||
|
// Es el caso que dejaba pasar a Principal: -win rompe el prefijo de carpeta.
|
||||||
|
expect(
|
||||||
|
isInsideWorkFolder('C:\\Aduanasoft\\CloudRestoreAS', [
|
||||||
|
'C:\\Aduanasoft\\CloudRestoreAS-win\\Entrada'
|
||||||
|
])
|
||||||
|
).toBe(false);
|
||||||
|
expect(isInsideWorkFolder('/srv/entradas', ['/srv/entrada'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignora carpetas vacías o sin reportar en lugar de bloquear', () => {
|
||||||
|
expect(isInsideWorkFolder('/opt/cloudrestoreas', [null, undefined, '', ' '])).toBe(false);
|
||||||
|
expect(isInsideWorkFolder('', ['/srv/entrada'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -189,6 +189,14 @@ export function isDefaultInstallPath(
|
|||||||
*
|
*
|
||||||
* Instalar el binario dentro de `Entrada`/`Procesados` sería grave: el agente vigila esa
|
* Instalar el binario dentro de `Entrada`/`Procesados` sería grave: el agente vigila esa
|
||||||
* carpeta y trataría de procesar sus propios archivos como si fueran respaldos.
|
* carpeta y trataría de procesar sus propios archivos como si fueran respaldos.
|
||||||
|
*
|
||||||
|
* La relación se mira en UN solo sentido, y eso es deliberado. La carpeta de trabajo **debajo**
|
||||||
|
* de la de instalación es el layout normal, no un error: `constants.py` del agente define
|
||||||
|
* `DIR_ENTRADA = APP_DIR / "Entrada"`, así que una instalación correcta en
|
||||||
|
* `/opt/cloudrestoreas` reporta `input_folder = /opt/cloudrestoreas/Entrada`. Cuando esto también
|
||||||
|
* rechazaba ese caso, ninguna instalación ni actualización podía pasar: fallaba en
|
||||||
|
* resolveInstallPath, antes de abrir el run, con un mensaje que decía justo lo contrario de lo
|
||||||
|
* que ocurría. Solo se salvaban las instalaciones fuera de la ruta por omisión, por accidente.
|
||||||
*/
|
*/
|
||||||
export function isInsideWorkFolder(
|
export function isInsideWorkFolder(
|
||||||
installPath: string | null | undefined,
|
installPath: string | null | undefined,
|
||||||
@@ -206,7 +214,7 @@ export function isInsideWorkFolder(
|
|||||||
return workFolders.some((folder) => {
|
return workFolders.some((folder) => {
|
||||||
const base = norm(folder);
|
const base = norm(folder);
|
||||||
if (!base) return false;
|
if (!base) return false;
|
||||||
return target === base || target.startsWith(`${base}/`) || base.startsWith(`${target}/`);
|
return target === base || target.startsWith(`${base}/`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -7,7 +7,13 @@
|
|||||||
* hace `echo '{password}' | sudo -S ...`.
|
* hace `echo '{password}' | sudo -S ...`.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { execRemote, psEncoded, shQuote, InstallError } from './cras-install';
|
import {
|
||||||
|
execRemote,
|
||||||
|
probeLinuxElevation,
|
||||||
|
psEncoded,
|
||||||
|
shQuote,
|
||||||
|
InstallError
|
||||||
|
} from './cras-install';
|
||||||
|
|
||||||
/** Cliente SFTP falso que expone un `client.exec` controlable, como el real. */
|
/** Cliente SFTP falso que expone un `client.exec` controlable, como el real. */
|
||||||
function fakeSftp(handler: (command: string) => { code?: number; stdout?: string; stderr?: string }) {
|
function fakeSftp(handler: (command: string) => { code?: number; stdout?: string; stderr?: string }) {
|
||||||
@@ -200,3 +206,91 @@ describe('no fuga de secretos en los comandos remotos', () => {
|
|||||||
expect(decoded).toContain('-PanelEnvFile');
|
expect(decoded).toContain('-PanelEnvFile');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sonda de elevación en Linux. No tenía ninguna prueba, y es la que decide si una instalación
|
||||||
|
* se aborta antes de transferir 270 MB — y ahora también si puede seguir sin privilegios.
|
||||||
|
*
|
||||||
|
* Lo que se cubre son los diagnósticos que estaban MAL: `requiretty` se reportaba como "sin
|
||||||
|
* privilegios", y el remedio que se ofrecía (configurar NOPASSWD) no arregla ese caso; y una
|
||||||
|
* regla NOPASSWD acotada a otros comandos se veía idéntica a no tener ninguna.
|
||||||
|
*/
|
||||||
|
describe('probeLinuxElevation', () => {
|
||||||
|
/** Responde por comando, con lo que devolvería el destino real. */
|
||||||
|
function sftpFor(responses: Record<string, { code?: number; stdout?: string; stderr?: string }>) {
|
||||||
|
return fakeSftp((command) => {
|
||||||
|
for (const [needle, result] of Object.entries(responses)) {
|
||||||
|
if (command.includes(needle)) return result;
|
||||||
|
}
|
||||||
|
return { code: 1, stdout: '', stderr: '' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('root: sin prefijo de elevación', async () => {
|
||||||
|
const { sftp } = sftpFor({ 'id -u': { code: 0, stdout: '0' } });
|
||||||
|
const r = await probeLinuxElevation(sftp as never);
|
||||||
|
expect(r).toMatchObject({ elevation: 'root', prefix: '', label: 'root' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sudo sin password: prefija con -n, nunca con -S', async () => {
|
||||||
|
const { sftp } = sftpFor({
|
||||||
|
'id -u': { code: 0, stdout: '1000' },
|
||||||
|
'sudo -n true': { code: 0 }
|
||||||
|
});
|
||||||
|
const r = await probeLinuxElevation(sftp as never);
|
||||||
|
expect(r.elevation).toBe('sudo-sin-password');
|
||||||
|
expect(r.prefix).toContain('-n');
|
||||||
|
expect(r.prefix).not.toContain('-S');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requiretty se distingue de "sin privilegios": el remedio es el opuesto', async () => {
|
||||||
|
const { sftp } = sftpFor({
|
||||||
|
'id -u': { code: 0, stdout: '1000' },
|
||||||
|
'sudo -n true': { code: 1, stderr: 'sudo: sorry, you must have a tty to run sudo' }
|
||||||
|
});
|
||||||
|
const r = await probeLinuxElevation(sftp as never);
|
||||||
|
expect(r.elevation).toBe('requiretty');
|
||||||
|
// Lo importante del mensaje: que NO mande a configurar NOPASSWD, que no arregla esto.
|
||||||
|
expect(r.detail).toMatch(/requiretty/);
|
||||||
|
expect(r.detail).toMatch(/NO lo arregla/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detecta reglas NOPASSWD acotadas a otros comandos', async () => {
|
||||||
|
const { sftp } = sftpFor({
|
||||||
|
'id -u': { code: 0, stdout: '1000' },
|
||||||
|
'sudo -n true': { code: 1, stderr: 'sudo: a password is required' },
|
||||||
|
'sudo -n -l': { code: 0, stdout: 'User srvmid_db may run:\n (root) NOPASSWD: /usr/bin/systemctl' }
|
||||||
|
});
|
||||||
|
const r = await probeLinuxElevation(sftp as never);
|
||||||
|
expect(r.elevation).toBe('ninguna');
|
||||||
|
expect(r.detail).toMatch(/acotadas/);
|
||||||
|
expect(r.detail).toContain('systemctl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sin privilegios de ninguna clase, y sin prefijo que pueda elevar', async () => {
|
||||||
|
const { sftp } = sftpFor({
|
||||||
|
'id -u': { code: 0, stdout: '1000' },
|
||||||
|
'sudo -n true': { code: 1, stderr: 'sudo: a password is required' },
|
||||||
|
'sudo -n -l': { code: 1, stdout: '' }
|
||||||
|
});
|
||||||
|
const r = await probeLinuxElevation(sftp as never);
|
||||||
|
expect(r.elevation).toBe('ninguna');
|
||||||
|
expect(r.prefix).toBe('');
|
||||||
|
expect(r.detail).toMatch(/contraseña, que el panel nunca envía/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NUNCA construye un prefijo que le pida la contraseña a sudo', async () => {
|
||||||
|
// La política del módulo: ni por argv ni por stdin. El modo sin privilegios existe
|
||||||
|
// precisamente para no tener que romperla.
|
||||||
|
for (const stderr of ['sudo: a password is required', 'sudo: sorry, you must have a tty']) {
|
||||||
|
const { sftp } = sftpFor({
|
||||||
|
'id -u': { code: 0, stdout: '1000' },
|
||||||
|
'sudo -n true': { code: 1, stderr },
|
||||||
|
'sudo -n -l': { code: 1 }
|
||||||
|
});
|
||||||
|
const r = await probeLinuxElevation(sftp as never);
|
||||||
|
expect(r.prefix).not.toMatch(/-S/);
|
||||||
|
expect(r.prefix).not.toMatch(/echo/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,8 +12,11 @@
|
|||||||
* subido por SFTP, no como argumento: `ps` y el historial del destino son legibles por
|
* subido por SFTP, no como argumento: `ps` y el historial del destino son legibles por
|
||||||
* otros usuarios. AServers hace `echo '{password}' | sudo -S ...`, que expone el password
|
* otros usuarios. AServers hace `echo '{password}' | sudo -S ...`, que expone el password
|
||||||
* y además permite inyección de comandos.
|
* y además permite inyección de comandos.
|
||||||
* - **Se exige root o `sudo -n`** (sudo sin password). Nunca se le pasa el password a sudo.
|
* - **Nunca se le pasa el password a sudo**, ni por argv ni por stdin. Para los modos que
|
||||||
* Si no hay privilegios se aborta ANTES de subir 270 MB.
|
* instalan en /opt y registran un servicio de sistema se exige root o `sudo -n` (sudo sin
|
||||||
|
* password), y si no hay ninguno se aborta ANTES de subir 270 MB. El modo `user-service`
|
||||||
|
* evita el problema en vez de rodearlo: instala bajo el home con un unit de systemd de
|
||||||
|
* usuario, así que no necesita elevación alguna y le bastan las credenciales SSH.
|
||||||
* - **El sha256 se verifica en el destino** antes de extraer, no solo al cachear: así se
|
* - **El sha256 se verifica en el destino** antes de extraer, no solo al cachear: así se
|
||||||
* detecta una transferencia corrupta.
|
* detecta una transferencia corrupta.
|
||||||
* - **El progreso se persiste paso a paso** en cras_install_runs.steps para que la UI lo
|
* - **El progreso se persiste paso a paso** en cras_install_runs.steps para que la UI lo
|
||||||
@@ -35,6 +38,7 @@ import {
|
|||||||
type InstallMode
|
type InstallMode
|
||||||
} from './cras-releases';
|
} from './cras-releases';
|
||||||
import { ensureCached } from './cras-artifacts';
|
import { ensureCached } from './cras-artifacts';
|
||||||
|
import type { AutostartMode } from '$lib/cras-install-progress';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import {
|
import {
|
||||||
effectiveArch,
|
effectiveArch,
|
||||||
@@ -149,7 +153,7 @@ export interface InstallRequest {
|
|||||||
/** Token de servicio que se sembrará en el .env del destino. */
|
/** Token de servicio que se sembrará en el .env del destino. */
|
||||||
panelApiToken: string;
|
panelApiToken: string;
|
||||||
/** Modo de arranque a registrar en el destino. */
|
/** Modo de arranque a registrar en el destino. */
|
||||||
autostart?: 'service' | 'desktop' | 'none';
|
autostart?: AutostartMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstallOutcome {
|
export interface InstallOutcome {
|
||||||
@@ -423,7 +427,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 +461,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.'
|
||||||
);
|
);
|
||||||
@@ -491,7 +499,21 @@ async function installLinux(
|
|||||||
// un Windows (o en un WSL dentro de un Windows) hay que abortar aquí y no tras subir 270 MB.
|
// un Windows (o en un WSL dentro de un Windows) hay que abortar aquí y no tras subir 270 MB.
|
||||||
const systemEvidence = await assertSystemMatches(sftp, 'linux');
|
const systemEvidence = await assertSystemMatches(sftp, 'linux');
|
||||||
|
|
||||||
const privileged = await resolveLinuxPrivilege(sftp, target.ssh_username);
|
// El modo `user-service` instala en el home con un unit de usuario, así que no necesita
|
||||||
|
// ninguna elevación. Los modos de sistema sí: ahí la falta de privilegios se aborta aquí,
|
||||||
|
// antes de transferir 270 MB, porque fallar después es desperdicio y deja basura en /tmp.
|
||||||
|
const autostartMode = request.autostart ?? 'service';
|
||||||
|
const privileged = await probeLinuxElevation(sftp);
|
||||||
|
if (autostartMode !== 'user-service' && !privileged.prefix && privileged.elevation !== 'root') {
|
||||||
|
throw new InstallError(
|
||||||
|
409,
|
||||||
|
`El usuario '${target.ssh_username}' no puede elevar privilegios en este servidor: ` +
|
||||||
|
`${privileged.detail} Para instalar en ${installPath} y registrar el servicio de ` +
|
||||||
|
'sistema hacen falta. Alternativas: usar una cuenta root, dar NOPASSWD a ese ' +
|
||||||
|
'usuario, o elegir el arranque "Servicio de usuario", que instala en el home y no ' +
|
||||||
|
'necesita privilegios.'
|
||||||
|
);
|
||||||
|
}
|
||||||
await appendInstallStep(
|
await appendInstallStep(
|
||||||
runId,
|
runId,
|
||||||
'precondiciones',
|
'precondiciones',
|
||||||
@@ -595,37 +617,83 @@ async function installLinux(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinuxPrivilege {
|
/**
|
||||||
/** Prefijo a poner delante de los comandos que requieren root. */
|
* Vías de elevación en Linux, de mejor a peor.
|
||||||
|
*
|
||||||
|
* `requiretty` se distingue de `ninguna` porque el remedio es el opuesto: con requiretty,
|
||||||
|
* agregar NOPASSWD no sirve de nada —sudo rechaza antes de mirar la política— y el consejo de
|
||||||
|
* "configura NOPASSWD" manda al operador a hacer algo inútil. Es un fallo de sudo, no de permisos.
|
||||||
|
*/
|
||||||
|
export type LinuxElevation = 'root' | 'sudo-sin-password' | 'requiretty' | 'ninguna';
|
||||||
|
|
||||||
|
export interface LinuxPrivilege {
|
||||||
|
/** Prefijo a poner delante de los comandos que requieren root. Vacío si no hay elevación. */
|
||||||
prefix: string;
|
prefix: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
elevation: LinuxElevation;
|
||||||
|
/** Qué falta exactamente, en términos accionables. Vacío cuando sí hay elevación. */
|
||||||
|
detail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resuelve cómo obtener privilegios: root directo o `sudo -n` (sin password).
|
* `Defaults requiretty` en el sudoers del destino (aún común en derivados de RHEL) hace fallar
|
||||||
*
|
* cualquier sudo lanzado sobre un `exec` de SSH, que no tiene tty. El mensaje es estable desde
|
||||||
* Nunca se le pasa el password a sudo por stdin. Si no hay ninguna de las dos vías se
|
* hace dos décadas, así que la firma de stderr es fiable — y se clasifica sobre la SONDA, que es
|
||||||
* aborta aquí, antes de transferir el artefacto: fallar tras subir 270 MB es desperdicio y
|
* un comando controlado de una línea, no sobre la salida del instalador.
|
||||||
* deja basura en /tmp del destino.
|
|
||||||
*/
|
*/
|
||||||
async function resolveLinuxPrivilege(
|
function isRequireTty(stderr: string): boolean {
|
||||||
sftp: SftpClient,
|
return /must have a tty|no tty present/i.test(stderr);
|
||||||
sshUsername: string
|
}
|
||||||
): Promise<LinuxPrivilege> {
|
|
||||||
|
/**
|
||||||
|
* Sonda de elevación. La comparten el instalador y la pantalla de Verificar para que no puedan
|
||||||
|
* contradecirse: antes cada uno tenía su propia copia y ya diferían en la etiqueta.
|
||||||
|
*
|
||||||
|
* Nunca se le pasa el password a sudo. La vía sin privilegios no es un error aquí: el modo de
|
||||||
|
* instalación `user-service` no necesita ninguno, así que quien decide si falta algo es el
|
||||||
|
* llamador, no esta función.
|
||||||
|
*/
|
||||||
|
export async function probeLinuxElevation(sftp: SftpClient): Promise<LinuxPrivilege> {
|
||||||
const id = await execRemote(sftp, 'id -u');
|
const id = await execRemote(sftp, 'id -u');
|
||||||
if (id.code === 0 && id.stdout.trim() === '0') {
|
if (id.code === 0 && id.stdout.trim() === '0') {
|
||||||
return { prefix: '', label: 'root' };
|
return { prefix: '', label: 'root', elevation: 'root', detail: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const sudo = await execRemote(sftp, 'sudo -n true');
|
const sudo = await execRemote(sftp, 'sudo -n true');
|
||||||
if (sudo.code === 0) {
|
if (sudo.code === 0) {
|
||||||
return { prefix: 'sudo -n ', label: 'sudo sin password' };
|
return {
|
||||||
|
prefix: 'sudo -n ',
|
||||||
|
label: 'sudo sin password',
|
||||||
|
elevation: 'sudo-sin-password',
|
||||||
|
detail: ''
|
||||||
|
};
|
||||||
}
|
}
|
||||||
throw new InstallError(
|
if (isRequireTty(sudo.stderr)) {
|
||||||
409,
|
return {
|
||||||
`El usuario '${sshUsername}' no es root y no tiene sudo sin password. ` +
|
prefix: '',
|
||||||
'Configura NOPASSWD para ese usuario o usa una cuenta root: el instalador necesita ' +
|
label: 'sudo bloqueado por requiretty',
|
||||||
'privilegios para el servicio systemd y, por seguridad, no se le pasa la contraseña a sudo.'
|
elevation: 'requiretty',
|
||||||
);
|
detail:
|
||||||
|
'el sudoers del destino tiene `Defaults requiretty` y el panel ejecuta sin tty. ' +
|
||||||
|
'Agregar NOPASSWD NO lo arregla: hay que quitar esa opción o excluir al usuario ' +
|
||||||
|
'con `Defaults:<usuario> !requiretty`.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// `sudo -n true` da falso negativo cuando existe una regla NOPASSWD acotada a comandos
|
||||||
|
// concretos: `true` no está en ella, pero el comando real sí podría estarlo. Listar las
|
||||||
|
// reglas lo distingue, y de paso le dice al operador qué SÍ tiene concedido.
|
||||||
|
const list = await execRemote(sftp, 'sudo -n -l 2>&1 || true');
|
||||||
|
const scoped = list.code === 0 && /NOPASSWD:/i.test(list.stdout);
|
||||||
|
return {
|
||||||
|
prefix: '',
|
||||||
|
label: 'sin privilegios',
|
||||||
|
elevation: 'ninguna',
|
||||||
|
detail: scoped
|
||||||
|
? 'tiene reglas NOPASSWD pero acotadas a otros comandos: ' +
|
||||||
|
truncate(list.stdout.replace(/\s+/g, ' '))
|
||||||
|
: 'no es root y su sudo pide contraseña, que el panel nunca envía.'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyLinuxDeployment(
|
async function verifyLinuxDeployment(
|
||||||
@@ -633,12 +701,18 @@ async function verifyLinuxDeployment(
|
|||||||
runId: number,
|
runId: number,
|
||||||
release: CrasRelease,
|
release: CrasRelease,
|
||||||
privileged: LinuxPrivilege,
|
privileged: LinuxPrivilege,
|
||||||
autostart: 'service' | 'desktop' | 'none',
|
autostart: AutostartMode,
|
||||||
prefix: string
|
// Se llama installPath y no `prefix` a propósito: en el cuerpo convive con
|
||||||
|
// `privileged.prefix`, que es el prefijo de ELEVACIÓN. Dos cosas distintas con el mismo
|
||||||
|
// nombre en el mismo alcance es exactamente donde se cuela un bug silencioso.
|
||||||
|
installPath: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// El sello config/.version lo escribe el bootstrap del binario; es más confiable que
|
// El sello config/.version lo escribe el bootstrap del binario; es más confiable que
|
||||||
// stdout de --version, sobre todo por paridad con Windows (console=False).
|
// stdout de --version, sobre todo por paridad con Windows (console=False).
|
||||||
const stamp = await execRemote(sftp, `cat ${shQuote(`${prefix}/config/.version`)} 2>/dev/null`);
|
const stamp = await execRemote(
|
||||||
|
sftp,
|
||||||
|
`cat ${shQuote(`${installPath}/config/.version`)} 2>/dev/null`
|
||||||
|
);
|
||||||
const deployed = stamp.stdout.trim();
|
const deployed = stamp.stdout.trim();
|
||||||
if (deployed && deployed !== release.version) {
|
if (deployed && deployed !== release.version) {
|
||||||
throw new InstallError(
|
throw new InstallError(
|
||||||
@@ -653,18 +727,56 @@ async function verifyLinuxDeployment(
|
|||||||
deployed ? `config/.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)'
|
deployed ? `config/.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (autostart !== 'service') return;
|
if (autostart !== 'service' && autostart !== 'user-service') return;
|
||||||
|
|
||||||
const active = await execRemote(sftp, `${privileged.prefix}systemctl is-active cloudrestoreas`);
|
// El unit de usuario NO se consulta con el prefijo de elevación: vive en el bus del propio
|
||||||
|
// usuario, y preguntarlo como root apuntaría al bus equivocado y respondería 'inactive'
|
||||||
|
// sobre un servicio que sí está corriendo. XDG_RUNTIME_DIR va explícito porque un `exec` de
|
||||||
|
// SSH no es una sesión de login y no siempre lo trae.
|
||||||
|
const userMode = autostart === 'user-service';
|
||||||
|
const active = await execRemote(
|
||||||
|
sftp,
|
||||||
|
userMode
|
||||||
|
? 'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user is-active cloudrestoreas'
|
||||||
|
: `${privileged.prefix}systemctl is-active cloudrestoreas`
|
||||||
|
);
|
||||||
const state = active.stdout.trim() || active.stderr.trim();
|
const state = active.stdout.trim() || active.stderr.trim();
|
||||||
if (state !== 'active') {
|
if (state !== 'active') {
|
||||||
|
// En modo usuario el arranque puede haber quedado por cron (@reboot) en vez de systemd,
|
||||||
|
// cuando el destino no permite lingering. Ahí no hay unit que consultar y el proceso se
|
||||||
|
// comprueba directamente, que es lo que de verdad importa.
|
||||||
|
if (userMode) {
|
||||||
|
// El ancla ^ es obligatoria: sin ella el `sh -c` que corre este mismo pgrep lleva la
|
||||||
|
// ruta en su propia línea de comandos y haría match consigo mismo, reportando vivo un
|
||||||
|
// agente que no arrancó.
|
||||||
|
const alive = await execRemote(
|
||||||
|
sftp,
|
||||||
|
`pgrep -f ${shQuote(`^${installPath}/CloudRestoreAS`)} >/dev/null && echo si || echo no`
|
||||||
|
);
|
||||||
|
if (alive.stdout.trim() === 'si') {
|
||||||
|
await appendInstallStep(
|
||||||
|
runId,
|
||||||
|
'verificar-servicio',
|
||||||
|
true,
|
||||||
|
'proceso vivo (arranque por cron; systemd de usuario no disponible)'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
throw new InstallError(
|
throw new InstallError(
|
||||||
502,
|
502,
|
||||||
`El servicio cloudrestoreas no quedó activo (estado: ${state || 'desconocido'}). ` +
|
`El servicio cloudrestoreas no quedó activo (estado: ${state || 'desconocido'}). ` +
|
||||||
'Revisa journalctl -u cloudrestoreas en el servidor.'
|
(userMode
|
||||||
|
? 'Revisa `systemctl --user status cloudrestoreas` en el servidor.'
|
||||||
|
: 'Revisa journalctl -u cloudrestoreas en el servidor.')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await appendInstallStep(runId, 'verificar-servicio', true, 'cloudrestoreas active');
|
await appendInstallStep(
|
||||||
|
runId,
|
||||||
|
'verificar-servicio',
|
||||||
|
true,
|
||||||
|
userMode ? 'cloudrestoreas active (systemd de usuario)' : 'cloudrestoreas active'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -831,7 +943,7 @@ async function verifyWindowsDeployment(
|
|||||||
sftp: SftpClient,
|
sftp: SftpClient,
|
||||||
runId: number,
|
runId: number,
|
||||||
release: CrasRelease,
|
release: CrasRelease,
|
||||||
autostart: 'service' | 'desktop' | 'none',
|
autostart: AutostartMode,
|
||||||
prefix: string
|
prefix: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const stamp = await execRemote(
|
const stamp = await execRemote(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import net from 'node:net';
|
|||||||
import SftpClient from 'ssh2-sftp-client';
|
import SftpClient from 'ssh2-sftp-client';
|
||||||
|
|
||||||
import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg';
|
import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg';
|
||||||
import { execRemote, psEncoded, shQuote } from './cras-install';
|
import { execRemote, probeLinuxElevation, psEncoded, shQuote } from './cras-install';
|
||||||
import { listCrasTargetInventory } from './cras-releases';
|
import { listCrasTargetInventory } from './cras-releases';
|
||||||
import { DEFAULT_INSTALL_PATHS, effectiveInstallPath, type CrasPlatform } from '$lib/cras-version';
|
import { DEFAULT_INSTALL_PATHS, effectiveInstallPath, type CrasPlatform } from '$lib/cras-version';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
@@ -504,20 +504,65 @@ async function inspectLinux(
|
|||||||
const checks: VerifyCheck[] = [];
|
const checks: VerifyCheck[] = [];
|
||||||
const prefix = effectiveInstallPath(reportedInstallPath, 'linux') ?? DEFAULT_INSTALL_PATHS.linux;
|
const prefix = effectiveInstallPath(reportedInstallPath, 'linux') ?? DEFAULT_INSTALL_PATHS.linux;
|
||||||
|
|
||||||
const id = await execRemote(sftp, 'id -u', CHECK_TIMEOUT_MS);
|
// La sonda es la MISMA que usa el instalador. Antes había aquí una copia paralela, y ya
|
||||||
let privileged = id.code === 0 && id.stdout.trim() === '0';
|
// diferían en la etiqueta: dos pantallas contradiciéndose sobre el mismo hecho.
|
||||||
let privLabel = 'root';
|
const elevation = await probeLinuxElevation(sftp);
|
||||||
if (!privileged) {
|
const privileged = elevation.elevation === 'root' || elevation.elevation === 'sudo-sin-password';
|
||||||
const sudo = await execRemote(sftp, 'sudo -n true', CHECK_TIMEOUT_MS);
|
|
||||||
privileged = sudo.code === 0;
|
|
||||||
privLabel = privileged ? 'sudo sin contraseña' : 'sin privilegios';
|
|
||||||
}
|
|
||||||
checks.push(
|
checks.push(
|
||||||
check(
|
check(
|
||||||
'privilegios',
|
'privilegios',
|
||||||
'Privilegios para instalar',
|
'Privilegios para instalar',
|
||||||
privileged ? 'ok' : 'warn',
|
privileged ? 'ok' : 'warn',
|
||||||
privileged ? privLabel : 'no es root y no tiene sudo sin contraseña'
|
privileged ? elevation.label : `${elevation.label} — ${elevation.detail}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Qué haría falta para instalar SIN privilegios. Sin esto, el operador solo sabe que le
|
||||||
|
// faltan permisos, no cuál de los dos obstáculos tiene ni si puede rodearlos.
|
||||||
|
const caps = await execRemote(
|
||||||
|
sftp,
|
||||||
|
[
|
||||||
|
`echo "owner=$(stat -c '%U' ${shQuote(prefix)} 2>/dev/null || echo -)"`,
|
||||||
|
`echo "writable=$(test -w ${shQuote(prefix)} && echo si || echo no)"`,
|
||||||
|
`echo "home_writable=$(test -w "$HOME" && echo si || echo no)"`,
|
||||||
|
'echo "unit_system=$(test -f /etc/systemd/system/cloudrestoreas.service && echo si || echo no)"',
|
||||||
|
'echo "unit_user=$(test -f "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/cloudrestoreas.service" && echo si || echo no)"',
|
||||||
|
'echo "crontab=$(command -v crontab >/dev/null 2>&1 && echo si || echo no)"',
|
||||||
|
'echo "linger=$(loginctl show-user "$(id -un)" --property=Linger 2>/dev/null | cut -d= -f2)"'
|
||||||
|
].join('; '),
|
||||||
|
CHECK_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
const cap = new Map(
|
||||||
|
caps.stdout
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim().split('='))
|
||||||
|
.filter((parts) => parts.length === 2)
|
||||||
|
.map(([k, v]) => [k, v] as const)
|
||||||
|
);
|
||||||
|
const prefixWritable = cap.get('writable') === 'si';
|
||||||
|
const unitSystem = cap.get('unit_system') === 'si';
|
||||||
|
checks.push(
|
||||||
|
check(
|
||||||
|
'ruta-escribible',
|
||||||
|
'Ruta de instalación escribible',
|
||||||
|
prefixWritable ? 'ok' : 'warn',
|
||||||
|
prefixWritable
|
||||||
|
? `${prefix} (dueño: ${cap.get('owner') ?? '?'})`
|
||||||
|
: `${prefix} es de ${cap.get('owner') ?? '?'}: actualizar el binario ahí necesita privilegios`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const rootlessViable =
|
||||||
|
cap.get('home_writable') === 'si' &&
|
||||||
|
(cap.get('crontab') === 'si' || cap.get('linger') === 'yes');
|
||||||
|
checks.push(
|
||||||
|
check(
|
||||||
|
'instalacion-sin-privilegios',
|
||||||
|
'Instalación sin privilegios posible',
|
||||||
|
rootlessViable ? 'ok' : 'warn',
|
||||||
|
rootlessViable
|
||||||
|
? `sí: home escribible, ${cap.get('crontab') === 'si' ? 'hay crontab' : 'lingering activo'}` +
|
||||||
|
(unitSystem ? '. Ojo: ya hay un unit de SISTEMA que habría que retirar con root.' : '')
|
||||||
|
: 'no: sin home escribible y sin crontab ni lingering no hay dónde dejarlo arrancado'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -581,7 +626,16 @@ async function inspectLinux(
|
|||||||
agent_could_apply: false,
|
agent_could_apply: false,
|
||||||
notes: privileged
|
notes: privileged
|
||||||
? []
|
? []
|
||||||
: ['Antes hay que resolver los privilegios: se necesita root o sudo sin contraseña.']
|
: rootlessViable
|
||||||
|
? [
|
||||||
|
`Sin privilegios (${elevation.detail}), pero no hacen falta: elige el ` +
|
||||||
|
'arranque "Servicio de usuario" al instalar y apunta la ruta al home ' +
|
||||||
|
'del usuario. Bastan el usuario y la contraseña SSH ya registrados.'
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
`Sin privilegios: ${elevation.detail}`,
|
||||||
|
'Y tampoco es viable la instalación sin privilegios en este servidor.'
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -34,10 +34,22 @@ 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 type { AutostartMode } from '$lib/cras-install-progress';
|
||||||
import { logger } from '$lib/server/logger';
|
import { logger } from '$lib/server/logger';
|
||||||
|
|
||||||
async function requireAdmin(cookies: import('@sveltejs/kit').Cookies) {
|
async function requireAdmin(cookies: import('@sveltejs/kit').Cookies) {
|
||||||
@@ -64,6 +76,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 +184,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 +200,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 +378,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.
|
||||||
@@ -342,8 +492,16 @@ export const actions: Actions = {
|
|||||||
return fail(400, { error: 'Servidor o versión inválidos.' });
|
return fail(400, { error: 'Servidor o versión inválidos.' });
|
||||||
}
|
}
|
||||||
const mode = modeRaw === 'update' ? 'update' : 'install';
|
const mode = modeRaw === 'update' ? 'update' : 'install';
|
||||||
const autostart =
|
// Lista blanca explícita en vez de una cadena de ternarios: con cuatro modos, el patrón
|
||||||
autostartRaw === 'desktop' ? 'desktop' : autostartRaw === 'none' ? 'none' : 'service';
|
// 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.
|
||||||
|
const autostart: AutostartMode =
|
||||||
|
autostartRaw === 'desktop' ||
|
||||||
|
autostartRaw === 'none' ||
|
||||||
|
autostartRaw === 'user-service' ||
|
||||||
|
autostartRaw === 'service'
|
||||||
|
? autostartRaw
|
||||||
|
: 'service';
|
||||||
const platformAck = data.get('platform_ack')?.toString() ?? '';
|
const platformAck = data.get('platform_ack')?.toString() ?? '';
|
||||||
|
|
||||||
// Red de seguridad del formulario. Se valida ANTES de abrir el run porque la única guarda
|
// Red de seguridad del formulario. Se valida ANTES de abrir el run porque la única guarda
|
||||||
@@ -400,6 +558,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({
|
||||||
|
|||||||
@@ -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)}
|
||||||
@@ -1145,12 +1226,24 @@
|
|||||||
<option value="service">
|
<option value="service">
|
||||||
Servicio 24/7 headless (recomendado en servidor)
|
Servicio 24/7 headless (recomendado en servidor)
|
||||||
</option>
|
</option>
|
||||||
|
{#if installTarget?.platform === 'linux'}
|
||||||
|
<option value="user-service">
|
||||||
|
Servicio 24/7 de usuario (sin privilegios)
|
||||||
|
</option>
|
||||||
|
{/if}
|
||||||
<option value="desktop">Al iniciar sesión (requiere escritorio)</option>
|
<option value="desktop">Al iniciar sesión (requiere escritorio)</option>
|
||||||
<option value="none">Solo instalar, sin arranque automático</option>
|
<option value="none">Solo instalar, sin arranque automático</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="mt-1 text-xs text-slate-500">
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
En Linux requiere root o sudo sin contraseña; en Windows, cuenta
|
{#if installAutostart === 'user-service'}
|
||||||
administradora. Se valida antes de transferir el artefacto.
|
No necesita privilegios: instala en la ruta indicada arriba —que debe
|
||||||
|
ser escribible por el usuario SSH, normalmente bajo su home— y registra
|
||||||
|
un servicio de systemd <em>de usuario</em>. Bastan el usuario y la
|
||||||
|
contraseña que ya tiene el servidor registrados.
|
||||||
|
{:else}
|
||||||
|
En Linux requiere root o sudo sin contraseña; en Windows, cuenta
|
||||||
|
administradora. Se valida antes de transferir el artefacto.
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
Reference in New Issue
Block a user