diff --git a/.env.example b/.env.example index c75e5b2..b34ea3c 100644 --- a/.env.example +++ b/.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= diff --git a/src/lib/cras-install-progress.test.ts b/src/lib/cras-install-progress.test.ts index a33bdc0..716902d 100644 --- a/src/lib/cras-install-progress.test.ts +++ b/src/lib/cras-install-progress.test.ts @@ -44,12 +44,19 @@ describe('currentPhaseLabel', () => { }); describe('expectedStepCount', () => { - it('cuenta verificar-servicio solo en modo servicio', () => { - // `cras-install.ts` corta con `if (autostart !== 'service') return;` antes de ese paso. + it('cuenta verificar-servicio solo en los modos que dejan algo arrancado', () => { + // `cras-install.ts` corta antes de ese paso cuando el modo no arranca nada. expect(expectedStepCount('service')).toBe(11); expect(expectedStepCount('desktop')).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', () => { diff --git a/src/lib/cras-install-progress.ts b/src/lib/cras-install-progress.ts index 1059a8b..2b070b2 100644 --- a/src/lib/cras-install-progress.ts +++ b/src/lib/cras-install-progress.ts @@ -17,7 +17,12 @@ export interface InstallStepView { 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'; /** @@ -87,7 +92,10 @@ export function currentPhaseLabel(steps: InstallStepView[]): string { /** Pasos esperados del camino feliz según el modo de arranque. */ 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 - 1; } diff --git a/src/lib/cras-version.test.ts b/src/lib/cras-version.test.ts index 9941016..7721d04 100644 --- a/src/lib/cras-version.test.ts +++ b/src/lib/cras-version.test.ts @@ -12,6 +12,9 @@ import { effectivePlatform, installPlatformVerdict, isCrasPlatform, + isCleanInstallConfirmed, + isInsideWorkFolder, + unseedablePanelUrlReason, isNewer, isValidVersion, osToPlatform, @@ -201,3 +204,127 @@ describe('installPlatformVerdict', () => { 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); + }); +}); + +/** + * URL del panel que se siembra en el agente. + * + * El aviso original solo comparaba que PANEL_PUBLIC_URL y ORIGIN coincidieran, y eso deja pasar + * el peor caso: que ambas valgan localhost, que es justo lo que produce el compose de desarrollo. + * Lo que hay que exigir es que la URL sea alcanzable desde OTRA máquina. + */ +describe('unseedablePanelUrlReason', () => { + it('rechaza loopback en todas sus formas, aunque coincida con ORIGIN', () => { + // Es el hueco que se está cerrando: en el servidor destino, localhost es ese servidor. + expect(unseedablePanelUrlReason('https://localhost:3000')).toMatch(/localhost/); + expect(unseedablePanelUrlReason('http://127.0.0.1:3000')).toMatch(/127\.0\.0\.1/); + expect(unseedablePanelUrlReason('http://127.1.2.3:3000')).not.toBeNull(); + expect(unseedablePanelUrlReason('http://[::1]:3000')).not.toBeNull(); + expect(unseedablePanelUrlReason('http://0.0.0.0:3000')).not.toBeNull(); + }); + + it('acepta una URL alcanzable, con y sin puerto', () => { + expect(unseedablePanelUrlReason('https://cpanel-a24.aduanasoft.com:3000')).toBeNull(); + // Sin puerto también pasa: el problema de esa es el desajuste con ORIGIN, que es un + // aviso distinto. Mezclar los dos haría imposible saber cuál corregir. + expect(unseedablePanelUrlReason('https://cpanel-a24.aduanasoft.com')).toBeNull(); + expect(unseedablePanelUrlReason('http://10.0.0.5:3000')).toBeNull(); + }); + + it('rechaza vacío, basura y protocolos que el agente no habla', () => { + expect(unseedablePanelUrlReason('')).toMatch(/no está configurada/); + expect(unseedablePanelUrlReason(null)).toMatch(/no está configurada/); + expect(unseedablePanelUrlReason('cpanel-a24.aduanasoft.com:3000')).not.toBeNull(); + expect(unseedablePanelUrlReason('ftp://cpanel-a24.aduanasoft.com')).toMatch(/ftp/); + }); +}); + +/** + * Confirmación de instalación limpia en otra ruta. + * + * Sin esto no se puede mover una instalación existente —por ejemplo al home, para instalar sin + * privilegios—: el panel pide `update` en cuanto hay versión instalada y assertExistingInstall + * exige que ya viva algo en la ruta destino. + */ +describe('isCleanInstallConfirmed', () => { + it('solo confirma si el ack es exactamente la ruta pedida', () => { + expect(isCleanInstallConfirmed('/home/srvmid_db/cras', '/home/srvmid_db/cras')).toBe(true); + expect(isCleanInstallConfirmed('/home/srvmid_db/cras', '/opt/cloudrestoreas')).toBe(false); + }); + + it('un ack de otra ruta NO sirve: la casilla no queda encendida si se cambia el destino', () => { + // Es la razón de que el ack lleve la ruta y no un booleano. + expect(isCleanInstallConfirmed('/home/otro/cras', '/home/srvmid_db/cras')).toBe(false); + expect(isCleanInstallConfirmed('/opt/cloudrestoreas', 'true')).toBe(false); + expect(isCleanInstallConfirmed('/opt/cloudrestoreas', 'on')).toBe(false); + }); + + it('tolera el separador final y los espacios, que no cambian la ruta', () => { + expect(isCleanInstallConfirmed('/home/srvmid_db/cras', '/home/srvmid_db/cras/')).toBe(true); + expect(isCleanInstallConfirmed('C:\\Aduanasoft\\CRAS', ' C:\\Aduanasoft\\CRAS\\ ')).toBe(true); + }); + + it('sin ack, o sin ruta pedida, nunca confirma', () => { + expect(isCleanInstallConfirmed('/opt/cloudrestoreas', null)).toBe(false); + expect(isCleanInstallConfirmed('/opt/cloudrestoreas', '')).toBe(false); + expect(isCleanInstallConfirmed('', '')).toBe(false); + expect(isCleanInstallConfirmed(null, null)).toBe(false); + }); +}); diff --git a/src/lib/cras-version.ts b/src/lib/cras-version.ts index 5457ab1..b2a8b2d 100644 --- a/src/lib/cras-version.ts +++ b/src/lib/cras-version.ts @@ -189,6 +189,14 @@ export function isDefaultInstallPath( * * 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. + * + * 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( installPath: string | null | undefined, @@ -206,10 +214,72 @@ export function isInsideWorkFolder( return workFolders.some((folder) => { const base = norm(folder); if (!base) return false; - return target === base || target.startsWith(`${base}/`) || base.startsWith(`${target}/`); + return target === base || target.startsWith(`${base}/`); }); } +/** Loopback en cualquiera de sus formas. `URL.hostname` devuelve IPv6 entre corchetes. */ +function isLoopbackHost(hostname: string): boolean { + const h = hostname.trim().toLowerCase().replace(/^\[|\]$/g, ''); + return h === 'localhost' || h === '::1' || h === '0.0.0.0' || /^127\./.test(h); +} + +/** + * Motivo por el que una URL del panel NO se puede sembrar en un agente, o null si sirve. + * + * Lo que importa no es que `PANEL_PUBLIC_URL` y `ORIGIN` coincidan, sino que la URL sea + * **alcanzable desde otra máquina**: se escribe en el config/.env del servidor destino, al + * instalar y también al actualizar. Comparar solo que coincidan deja pasar el peor caso — que + * ambas valgan `localhost`, que es lo que produce el compose de desarrollo—, y ahí cada agente + * acabaría hablando consigo mismo en su propio servidor. + */ +export function unseedablePanelUrlReason(url: string | null | undefined): string | null { + const value = String(url ?? '').trim(); + if (!value) return 'no está configurada'; + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return `no es una URL válida (${value})`; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return `usa el protocolo ${parsed.protocol.replace(':', '')} y el agente habla HTTP(S)`; + } + if (isLoopbackHost(parsed.hostname)) { + return `apunta a ${parsed.hostname}, que desde el servidor destino es ese mismo servidor`; + } + return null; +} + +/** Normaliza una ruta para compararla: sin separador final y sin espacios alrededor. */ +function normalizeInstallPath(path: string | null | undefined): string { + return String(path ?? '') + .trim() + .replace(/[/\\]+$/, ''); +} + +/** + * ¿El operador confirmó que quiere una instalación LIMPIA en esta ruta? + * + * Cuando un servidor ya tiene una versión instalada, el panel pide `update`, y una actualización + * exige que en la ruta destino ya viva una instalación (`assertExistingInstall`). Esa guarda es + * correcta —evita crear una segunda instalación con la configuración por omisión y dejar huérfano + * el .env personalizado—, pero bloquea el caso legítimo de mover la instalación a otra ruta, por + * ejemplo al home para instalar sin privilegios. + * + * El ack lleva la RUTA confirmada y no un booleano, igual que `installPlatformVerdict` lleva la + * plataforma: así una casilla que quedó marcada no sigue valiendo si después se cambia la ruta. + */ +export function isCleanInstallConfirmed( + requestedInstallPath: string | null | undefined, + ack: string | null | undefined +): boolean { + const requested = normalizeInstallPath(requestedInstallPath); + if (!requested) return false; + return normalizeInstallPath(ack) === requested; +} + /** Etiqueta legible para la UI. */ export function platformLabel(platform: string | null | undefined): string { if (platform === 'windows') return 'Windows'; diff --git a/src/lib/server/controldesk-pg.test.ts b/src/lib/server/controldesk-pg.test.ts index 9e0365a..b14cf80 100644 --- a/src/lib/server/controldesk-pg.test.ts +++ b/src/lib/server/controldesk-pg.test.ts @@ -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'); + }); +}); diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts index 4c7fd6e..a447a13 100644 --- a/src/lib/server/controldesk-pg.ts +++ b/src/lib/server/controldesk-pg.ts @@ -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 { + 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; diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index f7febd5..8cc9b07 100644 --- a/src/lib/server/cras-install.test.ts +++ b/src/lib/server/cras-install.test.ts @@ -7,7 +7,14 @@ * hace `echo '{password}' | sudo -S ...`. */ import { describe, expect, it, vi } from 'vitest'; -import { execRemote, psEncoded, shQuote, InstallError } from './cras-install'; +import { + execRemote, + probeInPlaceUpdate, + probeLinuxElevation, + psEncoded, + shQuote, + InstallError +} from './cras-install'; /** Cliente SFTP falso que expone un `client.exec` controlable, como el real. */ function fakeSftp(handler: (command: string) => { code?: number; stdout?: string; stderr?: string }) { @@ -200,3 +207,194 @@ describe('no fuga de secretos en los comandos remotos', () => { 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) { + 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/); + } + }); +}); + +/** + * Precondiciones de la actualización en sitio sin privilegios. + * + * Cada rechazo tiene un remedio distinto, y por eso no basta un booleano. Lo que se prueba aquí + * son los NO: el sí es trivial y el daño está en aceptar un servidor donde no se puede. + */ +describe('probeInPlaceUpdate', () => { + const PREFIX = '/opt/cloudrestoreas'; + + function sftpFor(responses: Record) { + return fakeSftp((command) => { + for (const [needle, result] of Object.entries(responses)) { + if (command.includes(needle)) return result; + } + return { code: 0, stdout: '' }; + }); + } + + /** Destino ideal: ruta del usuario, unit suyo con Restart=always, sin jobs. */ + const OK = { + 'test -w': { code: 0, stdout: 'si' }, + 'systemctl show': { code: 0, stdout: 'User=srvmid_db\nRestart=always' }, + '/Temp': { code: 0, stdout: '' } + }; + + it('acepta cuando se cumplen las tres condiciones', async () => { + const { sftp } = sftpFor(OK); + expect(await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db')).toMatchObject({ ok: true }); + }); + + it('rechaza si el directorio no es escribible, y dice de quién es', async () => { + // Sin escritura en el DIRECTORIO no se puede reemplazar el binario, y es el único + // permiso que hace falta: `install` desvincula antes de crear. + const { sftp } = sftpFor({ + ...OK, + 'test -w': { code: 1, stdout: 'no' }, + 'stat -c': { code: 0, stdout: 'root' } + }); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason).toContain('root'); + expect(r.reason).toContain(PREFIX); + }); + + it('rechaza si el servicio corre como otro usuario', async () => { + // Si el unit quedó con User=root —alguien instaló desde un `sudo -i`— la cuenta SSH no + // puede señalizar el proceso y toda la vía se cae. + const { sftp } = sftpFor({ + ...OK, + 'systemctl show': { code: 0, stdout: 'User=root\nRestart=always' } + }); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/corre como 'root'/); + }); + + it('trata el User vacío como root, que es lo que hace systemd', async () => { + const { sftp } = sftpFor({ + ...OK, + 'systemctl show': { code: 0, stdout: 'User=\nRestart=always' } + }); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/root/); + }); + + it('rechaza sin Restart=always: matarlo lo dejaría muerto', async () => { + for (const restart of ['on-failure', 'no', '']) { + const { sftp } = sftpFor({ + ...OK, + 'systemctl show': { code: 0, stdout: `User=srvmid_db\nRestart=${restart}` } + }); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok, `Restart=${restart}`).toBe(false); + expect(r.reason).toMatch(/Restart=always/); + } + }); + + it('rechaza si hay una restauración en curso, antes de subir 270 MB', async () => { + // El mismo chequeo lo repite install.sh en el destino, pero hacerlo aquí evita la + // transferencia completa para nada. + const { sftp } = sftpFor({ ...OK, '/Temp': { code: 0, stdout: 'job-4212' } }); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/restauración en curso/); + }); + + it('el motivo del rechazo nunca queda vacío: cada caso tiene su remedio', async () => { + const casos = [ + { ...OK, 'test -w': { code: 1, stdout: 'no' } }, + { ...OK, 'systemctl show': { code: 0, stdout: 'User=otro\nRestart=always' } }, + { ...OK, 'systemctl show': { code: 0, stdout: 'User=srvmid_db\nRestart=no' } }, + { ...OK, '/Temp': { code: 0, stdout: 'job-1' } } + ]; + for (const caso of casos) { + const { sftp } = sftpFor(caso); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason.trim().length).toBeGreaterThan(20); + } + }); +}); diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index 406b6a5..f1933fb 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -12,8 +12,11 @@ * 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 * 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. - * Si no hay privilegios se aborta ANTES de subir 270 MB. + * - **Nunca se le pasa el password a sudo**, ni por argv ni por stdin. Para los modos que + * 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 * detecta una transferencia corrupta. * - **El progreso se persiste paso a paso** en cras_install_runs.steps para que la UI lo @@ -35,6 +38,7 @@ import { type InstallMode } from './cras-releases'; import { ensureCached } from './cras-artifacts'; +import type { AutostartMode } from '$lib/cras-install-progress'; import { logger } from './logger'; import { effectiveArch, @@ -149,7 +153,7 @@ export interface InstallRequest { /** Token de servicio que se sembrará en el .env del destino. */ panelApiToken: string; /** Modo de arranque a registrar en el destino. */ - autostart?: 'service' | 'desktop' | 'none'; + autostart?: AutostartMode; } export interface InstallOutcome { @@ -423,7 +427,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 +461,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.' ); @@ -491,12 +499,42 @@ async function installLinux( // 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 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); + const canElevate = privileged.elevation === 'root' || privileged.elevation === 'sudo-sin-password'; + + // Sin elevación quedan dos vías antes de rendirse, y ambas evitan pedirle la contraseña a + // sudo. `user-service` instala en el home; la actualización en sitio deja el unit como está + // y solo reemplaza el binario, que es lo único que una actualización necesita de verdad. + let inPlaceUpdate = false; + if (!canElevate && autostartMode !== 'user-service') { + if (request.mode !== 'update') { + throw new InstallError( + 409, + `El usuario '${target.ssh_username}' no puede elevar privilegios: ` + + `${privileged.detail} Una instalación NUEVA en ${installPath} necesita ` + + 'privilegios para registrar el servicio de sistema. Usa el arranque ' + + '"Servicio de usuario", que instala en el home y no los necesita.' + ); + } + const inPlace = await probeInPlaceUpdate(sftp, installPath, target.ssh_username); + if (!inPlace.ok) { + throw new InstallError( + 409, + `No se puede actualizar ${target.name} sin privilegios: ${inPlace.reason}` + ); + } + inPlaceUpdate = true; + } await appendInstallStep( runId, 'precondiciones', true, - `${systemEvidence}, privilegios=${privileged.label}` + `${systemEvidence}, privilegios=${privileged.label}` + + (inPlaceUpdate ? ', vía=actualización en sitio sin privilegios' : '') ); // Antes de transferir: si es una actualización, confirmar que ahí VIVE una instalación. @@ -569,7 +607,13 @@ async function installLinux( } const autostart = request.autostart ?? 'service'; - const installerFlags = autostart === 'none' ? '' : ` --${autostart}`; + // En la vía sin privilegios se usa --update-in-place, que no toca /etc ni systemctl: + // solo reemplaza el binario y señaliza al proceso para que Restart=always lo relevante. + const installerFlags = inPlaceUpdate + ? ' --update-in-place' + : autostart === 'none' + ? '' + : ` --${autostart}`; // PREFIX es variable de entorno en install.sh, no un flag. La ruta no es secreta, así // que pasarla por la línea de comandos está bien; el token sí va por archivo. const installCmd = @@ -579,6 +623,17 @@ async function installLinux( await appendInstallStep(runId, 'ejecutar-instalador', true, `install.sh${installerFlags}`); const install = await execRemote(sftp, installCmd, INSTALL_EXEC_TIMEOUT_MS); + // 75 = EX_TEMPFAIL, que install.sh reserva para "hay una restauración en curso". Es un + // conflicto de estado, no un fallo: hay que reintentar, no investigar. Con el 502 genérico + // parecía que la instalación se había roto, y el operador iría a buscar la causa equivocada. + if (install.code === 75) { + throw new InstallError( + 409, + `${target.name} está restaurando un respaldo ahora mismo, así que no se actualizó ` + + 'para no interrumpirlo —eso dejaría ese respaldo vetado y la base en ' + + 'SINGLE_USER—. Reintenta cuando termine.' + ); + } if (install.code !== 0) { throw new InstallError( 502, @@ -587,7 +642,9 @@ async function installLinux( } // --- Verificación del despliegue ------------------------------------- - await verifyLinuxDeployment(sftp, runId, release, privileged, autostart, installPath); + await verifyLinuxDeployment( + sftp, runId, release, privileged, autostart, installPath, inPlaceUpdate + ); } finally { // Limpieza siempre: el panel.env trae el token en claro. Si falla NO se silencia — // significa que el token se quedó en el servidor y alguien tiene que ir a borrarlo. @@ -595,37 +652,184 @@ 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; 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). - * - * Nunca se le pasa el password a sudo por stdin. Si no hay ninguna de las dos vías se - * aborta aquí, antes de transferir el artefacto: fallar tras subir 270 MB es desperdicio y - * deja basura en /tmp del destino. + * `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 + * hace dos décadas, así que la firma de stderr es fiable — y se clasifica sobre la SONDA, que es + * un comando controlado de una línea, no sobre la salida del instalador. */ -async function resolveLinuxPrivilege( - sftp: SftpClient, - sshUsername: string -): Promise { +function isRequireTty(stderr: string): boolean { + return /must have a tty|no tty present/i.test(stderr); +} + +/** + * 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 { const id = await execRemote(sftp, 'id -u'); 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'); 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( - 409, - `El usuario '${sshUsername}' no es root y no tiene sudo sin password. ` + - 'Configura NOPASSWD para ese usuario o usa una cuenta root: el instalador necesita ' + - 'privilegios para el servicio systemd y, por seguridad, no se le pasa la contraseña a sudo.' + if (isRequireTty(sudo.stderr)) { + return { + prefix: '', + label: 'sudo bloqueado por requiretty', + 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: !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.' + }; +} + +/** + * Propiedades de un unit de systemd, leídas con `systemctl show`, que NO requiere privilegios + * (a diferencia de start/stop). + * + * Vive aquí y se comparte con la sonda de verificación porque ya hubo una divergencia por copiar + * esta lógica: la resolución de elevación existía duplicada en cras-verify y en cras-install, y + * las dos pantallas acabaron diciendo cosas distintas del mismo servidor. + * + * `User=` vacío significa root: es lo que systemd asume cuando el unit no lo declara. Tratarlo + * como cadena vacía haría creer que el servicio corre como un usuario sin nombre. + */ +export async function readUnitProps( + sftp: SftpClient, + unitName: string, + props: string[] +): Promise> { + const flags = props.map((name) => `-p ${name}`).join(' '); + const out = await execRemote(sftp, `systemctl show ${flags} ${unitName} 2>/dev/null`); + const parsed = new Map(); + for (const line of out.stdout.split('\n')) { + const at = line.indexOf('='); + if (at <= 0) continue; + parsed.set(line.slice(0, at).trim(), line.slice(at + 1).trim()); + } + return parsed; +} + +/** Usuario efectivo de un unit: `User=` vacío es root, igual que lo interpreta systemd. */ +export function unitUserOrRoot(props: Map): string { + return props.get('User') || 'root'; +} + +/** + * ¿Se puede ACTUALIZAR en su sitio sin privilegios? Devuelve el motivo si no. + * + * Existe porque hay entornos donde no se usa root en absoluto. Una actualización con el unit ya + * registrado no necesita privilegios si se cumplen tres cosas, y las tres se comprueban por SSH + * antes de subir 270 MB: + * + * 1. `install` puede reemplazar el binario. Y no exige permiso sobre el ARCHIVO: a diferencia de + * `cp`, desvincula el destino antes de crearlo (por eso `make install` funciona sobre binarios + * en ejecución), así que lo que hace falta es escritura en el DIRECTORIO. + * 2. El unit corre con el mismo usuario de la sesión SSH, para poder señalizar al proceso. Si + * quedó con `User=root` —porque alguien instaló desde un `sudo -i`— esta vía no aplica. + * 3. El unit trae `Restart=always`, que es quien vuelve a levantarlo con el binario nuevo. Sin + * eso, señalizarlo lo dejaría muerto. + */ +export async function probeInPlaceUpdate( + sftp: SftpClient, + installPath: string, + sshUsername: string +): Promise<{ ok: boolean; reason: string }> { + const writable = await execRemote( + sftp, + `test -w ${shQuote(installPath)} && echo si || echo no` ); + if (writable.stdout.trim() !== 'si') { + const owner = await execRemote(sftp, `stat -c '%U' ${shQuote(installPath)} 2>/dev/null`); + return { + ok: false, + reason: + `'${sshUsername}' no puede escribir en ${installPath} (dueño: ` + + `${owner.stdout.trim() || 'desconocido'}), así que no puede reemplazar el binario.` + }; + } + + const props = await readUnitProps(sftp, 'cloudrestoreas', ['User', 'Restart']); + const unitUser = unitUserOrRoot(props); + if (unitUser !== sshUsername) { + return { + ok: false, + reason: + `el servicio corre como '${unitUser}' y la sesión es de '${sshUsername}', así que ` + + 'esta cuenta no puede reiniciarlo sin privilegios.' + }; + } + if (props.get('Restart') !== 'always') { + return { + ok: false, + reason: + `el unit tiene Restart=${props.get('Restart') || 'desconocido'}; sin Restart=always ` + + 'nadie volvería a levantar el agente tras actualizarlo.' + }; + } + + // Se mira aquí además de en install.sh para no subir 270 MB en balde. + const busy = await execRemote( + sftp, + `test -d ${shQuote(`${installPath}/Temp`)} && ls -A ${shQuote(`${installPath}/Temp`)} 2>/dev/null | head -1` + ); + if (busy.stdout.trim()) { + return { + ok: false, + reason: + 'hay una restauración en curso. Interrumpirla dejaría ese respaldo vetado para ' + + 'siempre y la base en SINGLE_USER, así que se prefiere esperar. Reintenta luego.' + }; + } + return { ok: true, reason: '' }; } async function verifyLinuxDeployment( @@ -633,17 +837,40 @@ async function verifyLinuxDeployment( runId: number, release: CrasRelease, privileged: LinuxPrivilege, - autostart: 'service' | 'desktop' | 'none', - prefix: string + autostart: AutostartMode, + // 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, + inPlaceUpdate = false ): Promise { // 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). - const stamp = await execRemote(sftp, `cat ${shQuote(`${prefix}/config/.version`)} 2>/dev/null`); - const deployed = stamp.stdout.trim(); + // + // En la vía en sitio se OMITE el bootstrap y el sello lo escribe el proceso al reiniciarse, + // así que durante unos segundos sigue teniendo la versión ANTERIOR. Leerlo una sola vez + // convertía una actualización correcta en un fallo "reporta 1.0.0, se esperaba 1.1.1", con el + // agente ya actualizado y corriendo. Se sondea hasta que coincida, o hasta agotar el margen. + let deployed = ''; + const stampAttempts = inPlaceUpdate ? 15 : 1; + for (let attempt = 0; attempt < stampAttempts; attempt += 1) { + if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2000)); + const stamp = await execRemote( + sftp, + `cat ${shQuote(`${installPath}/config/.version`)} 2>/dev/null` + ); + deployed = stamp.stdout.trim(); + // Vacío = todavía no lo escribió, y eso ya se toleraba antes. + if (!deployed || deployed === release.version) break; + } if (deployed && deployed !== release.version) { throw new InstallError( 502, - `El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}` + `El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}. ` + + (inPlaceUpdate + ? 'El sello no cambió tras reiniciar el agente, así que el proceso puede seguir ' + + 'ejecutando el binario anterior.' + : '') ); } await appendInstallStep( @@ -653,18 +880,61 @@ async function verifyLinuxDeployment( 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. + // En la vía en sitio el agente acaba de reiniciarse: `is-active` puede devolver 'activating' + // durante unos segundos, y eso no es un fallo. install.sh ya confirmó que el proceso volvió, + // así que el proceso vivo es la evidencia que manda. + 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(); 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 || inPlaceUpdate) { + // 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, + inPlaceUpdate + ? `proceso vivo con el binario nuevo (unit intacto, estado: ${state})` + : 'proceso vivo (arranque por cron; systemd de usuario no disponible)' + ); + return; + } + } throw new InstallError( 502, `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 +1101,7 @@ async function verifyWindowsDeployment( sftp: SftpClient, runId: number, release: CrasRelease, - autostart: 'service' | 'desktop' | 'none', + autostart: AutostartMode, prefix: string ): Promise { const stamp = await execRemote( diff --git a/src/lib/server/cras-verify.test.ts b/src/lib/server/cras-verify.test.ts index 2bbf883..fb42403 100644 --- a/src/lib/server/cras-verify.test.ts +++ b/src/lib/server/cras-verify.test.ts @@ -14,7 +14,7 @@ * comando POSIX. */ import { describe, expect, it } from 'vitest'; -import { probeRemoteSystem } from './cras-verify'; +import { posixModeAllowsNonOwnerRead, probeRemoteSystem } from './cras-verify'; /** * Cliente SFTP falso. `responder` decide qué contesta cada comando; se registran todos los @@ -197,3 +197,41 @@ describe('TRIPWIRE: ningún comando POSIX en la rama Windows', () => { } }); }); + +/** + * Bit de lectura del modo POSIX. Decide si un servicio que corre como OTRO usuario puede leer + * config/.env. El caso que motiva esto es el 0600 que deja una instalación con sudo: el agente + * no puede leer su propia configuración y la sonda lo pintaba verde. + */ +describe('posixModeAllowsNonOwnerRead', () => { + it('0600 no deja leer a nadie más que al dueño', () => { + expect(posixModeAllowsNonOwnerRead('600')).toBe(false); + expect(posixModeAllowsNonOwnerRead('0600')).toBe(false); + }); + + it('detecta lectura por grupo y por otros, juntas o por separado', () => { + expect(posixModeAllowsNonOwnerRead('640')).toBe(true); + expect(posixModeAllowsNonOwnerRead('604')).toBe(true); + expect(posixModeAllowsNonOwnerRead('644')).toBe(true); + expect(posixModeAllowsNonOwnerRead('755')).toBe(true); + }); + + it('mira el bit 4, no el valor: 2 y 1 no son lectura', () => { + expect(posixModeAllowsNonOwnerRead('620')).toBe(false); + expect(posixModeAllowsNonOwnerRead('611')).toBe(false); + expect(posixModeAllowsNonOwnerRead('633')).toBe(false); + expect(posixModeAllowsNonOwnerRead('664')).toBe(true); + }); + + it('con 4 dígitos ignora el de setuid/sticky, que no afecta la lectura', () => { + expect(posixModeAllowsNonOwnerRead('4600')).toBe(false); + expect(posixModeAllowsNonOwnerRead('1644')).toBe(true); + }); + + it('un modo ilegible no se interpreta como permisivo', () => { + // stat devuelve '-' cuando el archivo no existe; asumir lectura ahí seria el error caro. + for (const bad of ['-', '', ' ', 'rw-------', '8', '99', '60']) { + expect(posixModeAllowsNonOwnerRead(bad), bad).toBe(false); + } + }); +}); diff --git a/src/lib/server/cras-verify.ts b/src/lib/server/cras-verify.ts index 07a7384..1671dac 100644 --- a/src/lib/server/cras-verify.ts +++ b/src/lib/server/cras-verify.ts @@ -17,7 +17,14 @@ import net from 'node:net'; import SftpClient from 'ssh2-sftp-client'; import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg'; -import { execRemote, psEncoded, shQuote } from './cras-install'; +import { + execRemote, + probeLinuxElevation, + psEncoded, + readUnitProps, + shQuote, + unitUserOrRoot +} from './cras-install'; import { listCrasTargetInventory } from './cras-releases'; import { DEFAULT_INSTALL_PATHS, effectiveInstallPath, type CrasPlatform } from '$lib/cras-version'; import { logger } from './logger'; @@ -497,6 +504,22 @@ interface InspectResult { remediation: Remediation | null; } +/** + * ¿El modo POSIX concede lectura a grupo o a otros? + * + * Se usa para decidir si un usuario que NO es el dueño del archivo puede leerlo. El caso que + * importa es el 0600 que deja una instalación con sudo: solo el dueño, así que un servicio que + * corra como otra cuenta no puede leer su propia configuración. + * + * Acepta 3 o 4 dígitos (el cuarto es el bit de setuid/sticky, que no afecta la lectura). + */ +export function posixModeAllowsNonOwnerRead(mode: string): boolean { + const digits = String(mode ?? '').trim(); + if (!/^[0-7]{3,4}$/.test(digits)) return false; + const [, group, other] = digits.slice(-3); + return (Number(group) & 4) !== 0 || (Number(other) & 4) !== 0; +} + async function inspectLinux( sftp: SftpClient, reportedInstallPath: string | null @@ -504,20 +527,65 @@ async function inspectLinux( const checks: VerifyCheck[] = []; const prefix = effectiveInstallPath(reportedInstallPath, 'linux') ?? DEFAULT_INSTALL_PATHS.linux; - const id = await execRemote(sftp, 'id -u', CHECK_TIMEOUT_MS); - let privileged = id.code === 0 && id.stdout.trim() === '0'; - let privLabel = 'root'; - if (!privileged) { - const sudo = await execRemote(sftp, 'sudo -n true', CHECK_TIMEOUT_MS); - privileged = sudo.code === 0; - privLabel = privileged ? 'sudo sin contraseña' : 'sin privilegios'; - } + // La sonda es la MISMA que usa el instalador. Antes había aquí una copia paralela, y ya + // diferían en la etiqueta: dos pantallas contradiciéndose sobre el mismo hecho. + const elevation = await probeLinuxElevation(sftp); + const privileged = elevation.elevation === 'root' || elevation.elevation === 'sudo-sin-password'; checks.push( check( 'privilegios', 'Privilegios para instalar', 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' ) ); @@ -543,18 +611,52 @@ async function inspectLinux( ); const deployed = version.stdout.trim(); - const env = await execRemote( + // `test -f` comprobaba EXISTENCIA, no lectura, y por eso este check salía verde justo en el + // caso roto: una instalación hecha con sudo deja config/.env en 0600 de root mientras el unit + // corre como un usuario común, que no puede leerlo. El agente no arranca y la pantalla decía + // "Configuración presente: ok". Se comprueba lectura y, además, quién es el dueño frente al + // usuario del unit — porque la sonda entra con la cuenta SSH, que no siempre es la del + // servicio. + const envProbe = await execRemote( sftp, - `test -f ${shQuote(`${prefix}/config/.env`)} && echo si || echo no`, + [ + `echo "existe=$(test -f ${shQuote(`${prefix}/config/.env`)} && echo si || echo no)"`, + `echo "legible=$(test -r ${shQuote(`${prefix}/config/.env`)} && echo si || echo no)"`, + `echo "dueno=$(stat -c '%U' ${shQuote(`${prefix}/config/.env`)} 2>/dev/null || echo -)"`, + `echo "modo=$(stat -c '%a' ${shQuote(`${prefix}/config/.env`)} 2>/dev/null || echo -)"` + ].join('; '), CHECK_TIMEOUT_MS ); - const hasEnv = env.stdout.trim() === 'si'; + const envInfo = new Map( + envProbe.stdout + .split('\n') + .map((line) => line.trim().split('=')) + .filter((parts) => parts.length === 2) + .map(([k, v]) => [k, v] as const) + ); + const hasEnv = envInfo.get('existe') === 'si'; + const envOwner = envInfo.get('dueno') ?? '-'; + const envMode = envInfo.get('modo') ?? '-'; + + // El unit puede correr como otro usuario que la sesión SSH. Un 0600 solo lo lee su dueño, así + // que si el dueño no es el usuario del servicio, el agente no puede leer su configuración + // aunque la sonda sí pueda. + const unitProps = await readUnitProps(sftp, 'cloudrestoreas', ['User']); + const serviceUser = unitUserOrRoot(unitProps); + const ownerIsService = envOwner === serviceUser; + const serviceCanRead = ownerIsService || posixModeAllowsNonOwnerRead(envMode); + checks.push( check( 'config', - 'Configuración presente', - hasEnv ? 'ok' : 'warn', - hasEnv ? `${prefix}/config/.env` : 'falta config/.env' + 'Configuración legible por el servicio', + !hasEnv ? 'warn' : serviceCanRead ? 'ok' : 'fail', + !hasEnv + ? 'falta config/.env' + : serviceCanRead + ? `${prefix}/config/.env (${envOwner}, ${envMode})` + : `${prefix}/config/.env es de '${envOwner}' en modo ${envMode}, pero el ` + + `servicio corre como '${serviceUser}': el agente no puede leerlo y no arrancará` ) ); @@ -581,7 +683,16 @@ async function inspectLinux( agent_could_apply: false, 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.' + ] } }; } diff --git a/src/lib/server/gitea-packages.test.ts b/src/lib/server/gitea-packages.test.ts index 1cda4c0..cb51f0f 100644 --- a/src/lib/server/gitea-packages.test.ts +++ b/src/lib/server/gitea-packages.test.ts @@ -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 ` 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 () => { diff --git a/src/lib/server/gitea-packages.ts b/src/lib/server/gitea-packages.ts index 0c82fe5..96938be 100644 --- a/src/lib/server/gitea-packages.ts +++ b/src/lib/server/gitea-packages.ts @@ -60,11 +60,43 @@ export function isGiteaConfigured(): boolean { return Boolean(env.GITEA_TOKEN && env.GITEA_TOKEN.trim()); } -function authHeaders(): Record { +/** + * 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 ` 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 { + 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(url: string): Promise { 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) { diff --git a/src/lib/server/service-auth.test.ts b/src/lib/server/service-auth.test.ts new file mode 100644 index 0000000..744861f --- /dev/null +++ b/src/lib/server/service-auth.test.ts @@ -0,0 +1,111 @@ +/** + * Autenticación servicio-a-servicio de los endpoints que consume CloudRestoreAS. + * + * La prueba que sostiene el resto es la de no-fuga: el rechazo de un token se escribe a los logs + * del contenedor, así que si alguna vez alguien mete el valor recibido "para depurar", el token de + * servicio —que es el MISMO en todos los agentes— acaba en disco y en cualquier agregador. + * + * El resto cubre por qué se loguea: hasta ahora un 401 no dejaba ni una línea, así que un token + * rotado dejaba a todos los agentes mudos y desde el panel se veía igual que un agente apagado. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { env } from '$env/dynamic/private'; + +const { loggerMock } = vi.hoisted(() => ({ + loggerMock: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } +})); +vi.mock('./logger', () => ({ logger: loggerMock })); + +import { checkServiceToken, logServiceTokenRejected } from './service-auth'; + +const TOKEN = 'tok-de-servicio-no-debe-aparecer-en-logs'; + +function req(authorization?: string): Request { + return new Request('https://panel.test/api/restore/target-for?database=X&instance=Mediano', { + headers: authorization ? { authorization } : {} + }); +} + +beforeEach(() => { + env.CLOUDRESTORE_API_TOKEN = TOKEN; + loggerMock.warn.mockClear(); +}); + +afterEach(() => { + // Cadena vacía y no `delete`: es el caso real del compose, que deja la variable definida y + // vacía cuando no se configuró. Y `delete` no tipa, porque la propiedad no es opcional. + env.CLOUDRESTORE_API_TOKEN = ''; + vi.restoreAllMocks(); +}); + +describe('checkServiceToken', () => { + it('acepta el token exacto', () => { + expect(checkServiceToken(req(`Bearer ${TOKEN}`))).toEqual({ ok: true }); + }); + + it('rechaza con 401 sin header, con esquema equivocado, o con token distinto', () => { + for (const header of [undefined, 'Basic abc', `Token ${TOKEN}`, 'Bearer otro-token']) { + expect(checkServiceToken(req(header)), String(header)).toMatchObject({ + ok: false, + status: 401 + }); + } + }); + + it('un token de largo distinto se rechaza sin comparar byte a byte', () => { + // timingSafeEqual lanza si los buffers difieren en largo, así que el guard de largo + // previo no es cosmético: sin él, un token más corto reventaría con una excepción. + expect(() => checkServiceToken(req('Bearer x'))).not.toThrow(); + expect(checkServiceToken(req('Bearer x'))).toMatchObject({ ok: false, status: 401 }); + }); + + it('sin token configurado responde 500, no 401: no se puede autenticar de forma segura', () => { + env.CLOUDRESTORE_API_TOKEN = ''; + expect(checkServiceToken(req(`Bearer ${TOKEN}`))).toMatchObject({ ok: false, status: 500 }); + }); +}); + +describe('logServiceTokenRejected', () => { + it('NUNCA registra el token, ni el esperado ni el recibido', () => { + logServiceTokenRejected({ + traceId: 't-1', + route: 'GET /api/restore/target-for', + request: req('Bearer un-token-que-no-cuadra'), + instanceKey: 'Mediano' + }); + const escrito = JSON.stringify(loggerMock.warn.mock.calls[0][0]); + expect(escrito).not.toContain(TOKEN); + expect(escrito).not.toContain('un-token-que-no-cuadra'); + // Ni fragmentos: un prefijo también acota el espacio de búsqueda. + expect(escrito).not.toContain(TOKEN.slice(0, 8)); + }); + + it('distingue "sin header" de "token no coincide": son remedios distintos', () => { + // Sin header = agente sin configurar. Token que no cuadra = token rotado. + logServiceTokenRejected({ traceId: 't-2', route: 'r', request: req() }); + expect(loggerMock.warn.mock.calls[0][0].context.reason).toMatch(/sin header/i); + + loggerMock.warn.mockClear(); + logServiceTokenRejected({ traceId: 't-3', route: 'r', request: req('Bearer nope') }); + expect(loggerMock.warn.mock.calls[0][0].context.reason).toMatch(/no coincide/i); + }); + + it('lleva trace_id y ruta, que es lo que permite correlacionar con el agente', () => { + logServiceTokenRejected({ + traceId: 't-4', + route: 'POST /api/restore/job-result', + request: req('Bearer nope') + }); + const entry = loggerMock.warn.mock.calls[0][0]; + expect(entry.trace_id).toBe('t-4'); + expect(entry.context.route).toBe('POST /api/restore/job-result'); + }); + + it('normaliza la instancia ausente o en blanco a null, no a cadena vacía', () => { + for (const ik of [undefined, null, '', ' ']) { + loggerMock.warn.mockClear(); + logServiceTokenRejected({ traceId: 't', route: 'r', request: req(), instanceKey: ik }); + expect(loggerMock.warn.mock.calls[0][0].context.instance_key, String(ik)).toBeNull(); + } + }); +}); diff --git a/src/lib/server/service-auth.ts b/src/lib/server/service-auth.ts index ca91933..48302a3 100644 --- a/src/lib/server/service-auth.ts +++ b/src/lib/server/service-auth.ts @@ -8,6 +8,7 @@ */ import { timingSafeEqual } from 'node:crypto'; import { env } from '$env/dynamic/private'; +import { logger } from './logger'; /** * Valida el header Authorization: Bearer contra CLOUDRESTORE_API_TOKEN. @@ -38,3 +39,35 @@ export function checkServiceToken(request: Request): { ok: true } | { ok: false; } return { ok: true }; } + +/** + * Registra un rechazo de token de servicio. + * + * Hasta ahora un 401 no dejaba **ni una línea** en el panel: solo se logueaba el 500 de "token no + * configurado". Con el token rotado, todos los agentes quedan mudos —dejan de reportar versión, de + * resolver rutas y de registrar resultados— y desde el panel eso se ve igual que un agente + * apagado. Peor: el `trace_id` que se le devuelve al agente en el cuerpo del 401 no existía del + * lado del servidor, así que era imposible de correlacionar. + * + * NUNCA se registra el token ni un fragmento suyo. Lo que sirve para diagnosticar es qué ruta se + * rechazó, si venía con header o sin él, y de qué instancia. + */ +export function logServiceTokenRejected(info: { + traceId: string; + route: string; + request: Request; + instanceKey?: string | null; +}): void { + const header = info.request.headers.get('authorization') ?? ''; + logger.warn({ + trace_id: info.traceId, + message: 'Token de servicio rechazado', + context: { + route: info.route, + // Distinguir "no mandó header" de "mandó uno que no cuadra" separa un agente sin + // configurar de un token rotado, que son dos problemas con remedios distintos. + reason: header ? 'token no coincide' : 'sin header Authorization', + instance_key: info.instanceKey?.trim() || null + } + }); +} diff --git a/src/routes/api/restore/agent-sync/+server.ts b/src/routes/api/restore/agent-sync/+server.ts index ff4bc3e..112647a 100644 --- a/src/routes/api/restore/agent-sync/+server.ts +++ b/src/routes/api/restore/agent-sync/+server.ts @@ -12,7 +12,7 @@ */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { checkServiceToken } from '$lib/server/service-auth'; +import { checkServiceToken, logServiceTokenRejected } from '$lib/server/service-auth'; import { errorJson, newTraceId } from '$lib/server/api-error'; import { logger } from '$lib/server/logger'; import { GiteaError, isGiteaConfigured } from '$lib/server/gitea-packages'; @@ -27,6 +27,12 @@ export const POST: RequestHandler = async ({ request }) => { logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); return errorJson(500, 'Servicio no configurado', traceId); } + logServiceTokenRejected({ + traceId, + route: 'POST /api/restore/agent-sync', + request, + instanceKey: null + }); return errorJson(401, 'Token de servicio ausente o inválido', traceId); } diff --git a/src/routes/api/restore/instance-config/+server.ts b/src/routes/api/restore/instance-config/+server.ts index 0483f5d..8ec3810 100644 --- a/src/routes/api/restore/instance-config/+server.ts +++ b/src/routes/api/restore/instance-config/+server.ts @@ -25,7 +25,7 @@ */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { checkServiceToken } from '$lib/server/service-auth'; +import { checkServiceToken, logServiceTokenRejected } from '$lib/server/service-auth'; import { upsertCloudRestoreStatus } from '$lib/server/controldesk-pg'; import { errorJson, newTraceId } from '$lib/server/api-error'; import { logger } from '$lib/server/logger'; @@ -74,6 +74,12 @@ export const POST: RequestHandler = async ({ request }) => { logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); return errorJson(500, 'Servicio no configurado', traceId); } + logServiceTokenRejected({ + traceId, + route: 'POST /api/restore/instance-config', + request, + instanceKey: null + }); return errorJson(401, 'Token de servicio ausente o inválido', traceId); } diff --git a/src/routes/api/restore/job-result/+server.ts b/src/routes/api/restore/job-result/+server.ts index c27e039..bb303c7 100644 --- a/src/routes/api/restore/job-result/+server.ts +++ b/src/routes/api/restore/job-result/+server.ts @@ -16,7 +16,7 @@ */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { checkServiceToken } from '$lib/server/service-auth'; +import { checkServiceToken, logServiceTokenRejected } from '$lib/server/service-auth'; import { insertRestoreJobLog } from '$lib/server/controldesk-pg'; import { errorJson, newTraceId } from '$lib/server/api-error'; import { logger } from '$lib/server/logger'; @@ -51,6 +51,12 @@ export const POST: RequestHandler = async ({ request }) => { logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); return errorJson(500, 'Servicio no configurado', traceId); } + logServiceTokenRejected({ + traceId, + route: 'POST /api/restore/job-result', + request, + instanceKey: null + }); return errorJson(401, 'Token de servicio ausente o inválido', traceId); } diff --git a/src/routes/api/restore/resolve-route/+server.ts b/src/routes/api/restore/resolve-route/+server.ts index 90e6b62..57c8323 100644 --- a/src/routes/api/restore/resolve-route/+server.ts +++ b/src/routes/api/restore/resolve-route/+server.ts @@ -6,7 +6,7 @@ */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { checkServiceToken } from '$lib/server/service-auth'; +import { checkServiceToken, logServiceTokenRejected } from '$lib/server/service-auth'; import { resolveRouteForFilename } from '$lib/server/controldesk-pg'; import { errorJson, newTraceId } from '$lib/server/api-error'; import { logger } from '$lib/server/logger'; @@ -20,6 +20,14 @@ export const GET: RequestHandler = async ({ request, url }) => { logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); return errorJson(500, 'Servicio no configurado', traceId); } + logServiceTokenRejected({ + traceId, + route: 'GET /api/restore/resolve-route', + request, + // El agente manda su instancia como `instance`; sirve para saber QUÉ servidor + // quedó mudo cuando se rota el token. + instanceKey: url.searchParams.get('instance') + }); return errorJson(401, 'Token de servicio ausente o inválido', traceId); } diff --git a/src/routes/api/restore/target-catalog/+server.ts b/src/routes/api/restore/target-catalog/+server.ts index b439021..0099998 100644 --- a/src/routes/api/restore/target-catalog/+server.ts +++ b/src/routes/api/restore/target-catalog/+server.ts @@ -6,7 +6,7 @@ */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { checkServiceToken } from '$lib/server/service-auth'; +import { checkServiceToken, logServiceTokenRejected } from '$lib/server/service-auth'; import { listRestoreTargets } from '$lib/server/controldesk-pg'; import { errorJson, newTraceId } from '$lib/server/api-error'; import { logger } from '$lib/server/logger'; @@ -20,6 +20,12 @@ export const GET: RequestHandler = async ({ request }) => { logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); return errorJson(500, 'Servicio no configurado', traceId); } + logServiceTokenRejected({ + traceId, + route: 'GET /api/restore/target-catalog', + request, + instanceKey: null + }); return errorJson(401, 'Token de servicio ausente o inválido', traceId); } diff --git a/src/routes/api/restore/target-for/+server.ts b/src/routes/api/restore/target-for/+server.ts index 7a15232..a572bc0 100644 --- a/src/routes/api/restore/target-for/+server.ts +++ b/src/routes/api/restore/target-for/+server.ts @@ -11,7 +11,7 @@ */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { checkServiceToken } from '$lib/server/service-auth'; +import { checkServiceToken, logServiceTokenRejected } from '$lib/server/service-auth'; import { getRestoreTargetForDatabase } from '$lib/server/controldesk-pg'; import { errorJson, newTraceId } from '$lib/server/api-error'; import { logger } from '$lib/server/logger'; @@ -25,6 +25,14 @@ export const GET: RequestHandler = async ({ request, url }) => { logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); return errorJson(500, 'Servicio no configurado', traceId); } + logServiceTokenRejected({ + traceId, + route: 'GET /api/restore/target-for', + request, + // El agente manda su instancia como `instance`; sirve para saber QUÉ servidor + // quedó mudo cuando se rota el token. + instanceKey: url.searchParams.get('instance') + }); return errorJson(401, 'Token de servicio ausente o inválido', traceId); } diff --git a/src/routes/versiones-cras/+page.server.ts b/src/routes/versiones-cras/+page.server.ts index 731ead2..9301e3b 100644 --- a/src/routes/versiones-cras/+page.server.ts +++ b/src/routes/versiones-cras/+page.server.ts @@ -34,10 +34,25 @@ import { ArtifactError, type PrunePlan } from '$lib/server/cras-artifacts'; -import { installPlatformVerdict, platformLabel } from '$lib/cras-version'; +import { + effectiveInstallPath, + installPlatformVerdict, + isCleanInstallConfirmed, + isInsideWorkFolder, + isSafeInstallPath, + platformLabel, + unseedablePanelUrlReason +} 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 type { AutostartMode } from '$lib/cras-install-progress'; import { logger } from '$lib/server/logger'; async function requireAdmin(cookies: import('@sveltejs/kit').Cookies) { @@ -64,6 +79,69 @@ function panelApiToken(): string { return (env.CLOUDRESTORE_API_TOKEN || '').trim(); } +/** + * Problemas de la URL del panel que se siembra en el agente. + * + * Esta URL se escribe en el config/.env del destino, y el instalador la reescribe en CADA + * actualización, no solo al instalar. Una URL equivocada no falla al capturarla: rompe el servidor + * la próxima vez que se actualice, y de golpe —el agente deja de reportar y de resolver rutas de + * restauración—. En producción le faltaba el puerto y el 443 de ese host lo sirve otro servicio, + * así que todo lo que pidiera el agente habría caído en un 404 ajeno. + * + * Se revisan DOS cosas distintas, en este orden: + * + * 1. Que la URL efectiva sea **sembrable** (alcanzable desde otra máquina). Es lo que de verdad + * rompe, y no se puede deducir de que coincida con ORIGIN. + * 2. Que PANEL_PUBLIC_URL y ORIGIN coincidan, como señal de que una de las dos está mal escrita. + * + * Ojo con una tentación: NO se puede suponer que ORIGIN es correcto porque la pantalla responde. + * `svelte.config.js` tiene `csrf.checkOrigin: false`, así que adapter-node no valida el Origin de + * los POST y un ORIGIN equivocado nunca se manifiesta por esa vía. + */ +function panelUrlProblem(): string | null { + const declared = (env.PANEL_PUBLIC_URL || '').trim(); + const origin = (env.ORIGIN || '').trim(); + const effective = declared || origin; + + // Primero lo que de verdad rompe: que la URL sembrada no sea alcanzable desde otra máquina. + // Comparar solo que declared y origin coincidan dejaba pasar el peor caso —ambas en + // localhost, que es lo que produce el compose de desarrollo— sin decir nada. + const unseedable = unseedablePanelUrlReason(effective); + if (unseedable) { + const which = declared ? 'PANEL_PUBLIC_URL' : 'ORIGIN'; + return ( + `La URL que se sembrará en el agente (${which}) ${unseedable}. Se escribe en el ` + + 'config/.env del servidor destino al instalar y al actualizar, así que tiene que ser ' + + 'alcanzable DESDE ese servidor.' + ); + } + if (!declared || !origin) return null; + + let declaredUrl: URL; + 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; + + // El consejo de "comenta PANEL_PUBLIC_URL y toma ORIGIN" solo vale si ORIGIN sirve para + // sembrar. Darlo a ciegas cuando ORIGIN es loopback empeora las cosas: sembraría localhost. + const originUnseedable = unseedablePanelUrlReason(origin); + return ( + `PANEL_PUBLIC_URL (${declaredUrl.origin}) no coincide con el origen del panel ` + + `(${originUrl.origin}). Es la URL que se siembra en el agente al instalar y al actualizar, ` + + 'así que apuntaría a otro servicio. ' + + (originUnseedable + ? `Y ORIGIN tampoco sirve para sembrar: ${originUnseedable}. Corrige AMBAS con una URL ` + + 'alcanzable desde los servidores de restauración, incluyendo el puerto.' + : 'Corrige el puerto, o comenta PANEL_PUBLIC_URL para que tome ORIGIN.') + ); +} + export const load: PageServerLoad = async ({ cookies }) => { const currentUser = await requireAdmin(cookies); let dbWarning: string | null = null; @@ -137,10 +215,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 +231,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 urlProblem = panelUrlProblem(); + if (urlProblem) { + configWarnings.push(urlProblem); + } return { currentUser, @@ -326,6 +409,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>[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. @@ -341,9 +522,17 @@ export const actions: Actions = { if (!targetId || !releaseId) { return fail(400, { error: 'Servidor o versión inválidos.' }); } - const mode = modeRaw === 'update' ? 'update' : 'install'; - const autostart = - autostartRaw === 'desktop' ? 'desktop' : autostartRaw === 'none' ? 'none' : 'service'; + const requestedMode = modeRaw === 'update' ? 'update' : 'install'; + // Lista blanca explícita en vez de una cadena de ternarios: con cuatro modos, el patrón + // anterior convertía cualquier valor no reconocido en 'service', que es justo el que + // exige privilegios. Un formulario viejo pediría el modo más restrictivo sin quererlo. + const autostart: AutostartMode = + autostartRaw === 'desktop' || + autostartRaw === 'none' || + autostartRaw === 'user-service' || + autostartRaw === 'service' + ? autostartRaw + : 'service'; const platformAck = data.get('platform_ack')?.toString() ?? ''; // Red de seguridad del formulario. Se valida ANTES de abrir el run porque la única guarda @@ -400,6 +589,35 @@ 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 urlProblem = panelUrlProblem(); + if (urlProblem) { + return fail(500, { error: urlProblem }); + } + + // Instalación LIMPIA en otra ruta. Sin esto no se puede mover una instalación existente + // —por ejemplo al home, para instalar sin privilegios—: el formulario pide `update` en + // cuanto hay versión instalada, y una actualización exige que en la ruta destino ya viva + // algo (assertExistingInstall). El ack lleva la RUTA confirmada, no un booleano, así que + // una casilla marcada deja de valer si después se cambia el destino. + const resolvedPath = effectiveInstallPath(target.reported_install_path, target.platform); + const cleanInstall = + requestedMode === 'update' && + isCleanInstallConfirmed(resolvedPath, data.get('clean_install_ack')?.toString()); + const mode = cleanInstall ? 'install' : requestedMode; + if (cleanInstall) { + logger.warn({ + message: 'Instalación limpia confirmada por el operador sobre un servidor con versión previa', + context: { + target: target.name, + install_path: resolvedPath, + previous_version: target.installed_version, + confirmed_by: currentUser.username + } + }); + } try { const outcome = await installCrasOnTarget({ diff --git a/src/routes/versiones-cras/+page.svelte b/src/routes/versiones-cras/+page.svelte index e11e815..d22b4c5 100644 --- a/src/routes/versiones-cras/+page.svelte +++ b/src/routes/versiones-cras/+page.svelte @@ -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, @@ -56,6 +56,20 @@ let installing = $state(false); /** Confirmación explícita de plataforma cuando el panel no la pudo determinar. */ let platformAck = $state(false); + /** Confirmación de instalar LIMPIO en la ruta elegida, sobre un servidor que ya tiene versión. */ + let cleanInstallAck = $state(false); + + /** + * Ruta donde instalará el panel: la misma que resuelve el servidor con effectiveInstallPath. + * Se muestra en vez del valor crudo porque lo que el operador necesita confirmar es dónde va + * a caer la instalación, no si la columna está en null. + */ + let targetInstallPath = $derived( + installTarget + ? (effectiveInstallPath(installTarget.reported_install_path, installTarget.platform) ?? + '(sin determinar)') + : '' + ); let installOptions = $derived( installTarget ? releasesFor(installTarget.platform) : [] @@ -76,6 +90,7 @@ installTarget = target; installAutostart = 'service'; platformAck = false; + cleanInstallAck = false; // Se preselecciona SOLO si el panel sabe la plataforma del servidor. Sin plataforma no se // adivina: `releasesFor(null)` es TODO el catálogo, ordenado por descubrimiento, así que // el fallback anterior proponía el artefacto más reciente de cualquier plataforma — un @@ -308,6 +323,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(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 +746,79 @@ {target.ssh_username}@{target.ssh_host} {/if} + + {#if editingPathTargetId === target.restore_target_id} +
(editingPathTargetId = null) + )} + class="mt-1 flex items-center gap-1" + > + + + + +
+ {:else} +
+ + {effectiveInstallPath( + target.reported_install_path, + target.platform + ) ?? '—'} + + {#if target.platform && !target.reported_install_path} + + (por omisión) + + {/if} + +
+ {/if} {platformLabel(target.platform)} @@ -1063,6 +1159,13 @@ name="mode" value={target.installed_version ? 'update' : 'install'} /> + + {/if} + + + {#if target.installed_version} +
+

+ warning_amber + ¿Instalar limpio en otra ruta? +

+

+ Actualizar exige que en {targetInstallPath} ya viva una + instalación. Si vas a mover el agente a otra carpeta —por ejemplo al + home, para instalar sin privilegios— marca esto: se instalará como + nuevo en esa ruta. +

+ + {#if cleanInstallAck} +

+ Ojo: el agente anterior sigue corriendo. Los dos + reportarían con el mismo nombre de instancia y se pisarían la + carpeta de entrada registrada, que es por donde el panel enruta + los respaldos. Detén el viejo en el servidor: + sudo systemctl disable --now cloudrestoreas +

+ {/if} +
+ {/if}
@@ -1145,12 +1287,24 @@ + {#if installTarget?.platform === 'linux'} + + {/if}

- En Linux requiere root o sudo sin contraseña; en Windows, cuenta - administradora. Se valida antes de transferir el artefacto. + {#if installAutostart === 'user-service'} + 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 de usuario. 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}