From d570c7dac2e56f4d144c5dce1e27827b27e9b925 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 30 Jul 2026 12:16:06 -0600 Subject: [PATCH 1/8] =?UTF-8?q?fix(versiones-cras):=20permitir=20fijar=20l?= =?UTF-8?q?a=20ruta=20de=20instalaci=C3=B3n=20y=20blindar=20la=20config=20?= =?UTF-8?q?de=20Gitea?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un agente anterior a 1.1.0 no reporta su install_path, así que el panel caía al default de plataforma y ACTUALIZAR abortaba cuando la instalación vivía en otra carpeta (p. ej. C:\Aduanasoft\CloudRestoreAS-win). Era un huevo-y-gallina: la ruta se empieza a reportar en 1.1.x, que es justo lo que no se podía instalar. Se agrega la captura manual de la ruta por servidor. No hace falta esquema nuevo ni lógica de resolución nueva: el upsert del estado ya usa install_path = COALESCE(EXCLUDED.install_path, actual), así que el valor capturado sobrevive los reportes sin ruta del agente viejo, y resolveInstallPath y cras-verify ya leen esa misma columna. Cuando el servidor quede en 1.1.x su propio reporte lo sustituye por la ruta real. De paso, dos fallos de configuración que costaron el diagnóstico: - GITEA_TOKEN con los `<>` de la plantilla se veía como un 401 opaco de Gitea, idéntico al de un token revocado. Se valida la forma antes de llamar, el aviso sale al cargar la pantalla y 401 y 403 dejan de colapsar al mismo texto. Un rechazo ahora deja línea en los logs: no dejaba ninguna. - PANEL_PUBLIC_URL sin el puerto apuntaba a otro servicio. Esa URL se siembra en el config/.env del destino también al ACTUALIZAR, así que rompería un agente que ya reportaba: se avisa y se aborta antes de tocar el servidor. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 17 +++ src/lib/server/controldesk-pg.test.ts | 96 ++++++++++++- src/lib/server/controldesk-pg.ts | 36 +++++ src/lib/server/cras-install.ts | 8 +- src/lib/server/gitea-packages.test.ts | 94 +++++++++++- src/lib/server/gitea-packages.ts | 57 +++++++- src/routes/versiones-cras/+page.server.ts | 168 +++++++++++++++++++++- src/routes/versiones-cras/+page.svelte | 83 ++++++++++- 8 files changed, 541 insertions(+), 18 deletions(-) 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/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.ts b/src/lib/server/cras-install.ts index 406b6a5..31e2397 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -423,7 +423,9 @@ async function resolveInstallPath( // Esa comprobación se hace con la sesión abierta, en assertExistingInstall(). if (request.mode === 'update' && !reportedInstallPath) { logger.warn({ - message: 'Actualización sin ruta reportada por el agente; se usará el default', + message: + 'Actualización sin ruta conocida (el agente no la reporta y no se capturó a mano); ' + + 'se usará el default', context: { target: target.name, install_path: resolved } }); } @@ -455,7 +457,9 @@ async function assertExistingInstall( `Se pidió ACTUALIZAR pero en ${installPath} no hay una instalación (falta config/.env). ` + (reportedPath ? `El agente reportó ${reportedPath}. ` - : 'El agente no ha reportado su ruta. ') + + : 'El agente no ha reportado su ruta —solo lo hace desde 1.1.0—, así que se usó la ' + + 'de omisión. Si la instalación vive en otra carpeta, captúrala con el lápiz que ' + + 'está junto al nombre del servidor y vuelve a intentar. ') + 'Se aborta para no crear una segunda instalación con la configuración por omisión ' + 'y dejar huérfano el .env personalizado. Si es una instalación nueva, usa Instalar.' ); 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/routes/versiones-cras/+page.server.ts b/src/routes/versiones-cras/+page.server.ts index 731ead2..4807810 100644 --- a/src/routes/versiones-cras/+page.server.ts +++ b/src/routes/versiones-cras/+page.server.ts @@ -34,9 +34,20 @@ import { ArtifactError, type PrunePlan } from '$lib/server/cras-artifacts'; -import { installPlatformVerdict, platformLabel } from '$lib/cras-version'; +import { + installPlatformVerdict, + isInsideWorkFolder, + isSafeInstallPath, + platformLabel +} from '$lib/cras-version'; +import { setCloudRestoreInstallPath } from '$lib/server/controldesk-pg'; import { syncCrasReleasesFromGitea } from '$lib/server/cras-sync'; -import { GiteaError, isGiteaConfigured, packageLocation } from '$lib/server/gitea-packages'; +import { + GiteaError, + giteaTokenProblem, + isGiteaConfigured, + packageLocation +} from '$lib/server/gitea-packages'; import { installCrasOnTarget, InstallError } from '$lib/server/cras-install'; import { logger } from '$lib/server/logger'; @@ -64,6 +75,41 @@ function panelApiToken(): string { return (env.CLOUDRESTORE_API_TOKEN || '').trim(); } +/** + * Aviso cuando PANEL_PUBLIC_URL apunta a un origen distinto del que sirve el panel. + * + * Esta URL se siembra en el config/.env del agente, y el instalador la reescribe en CADA + * actualización, no solo al instalar. Una URL equivocada no falla al capturarla: rompe el servidor + * la próxima vez que se actualice, y de golpe —el agente deja de reportar y de resolver rutas de + * restauración—. En producción le faltaba el puerto y el 443 de ese host lo sirve otro servicio, + * así que todo lo que pidiera el agente habría caído en un 404 ajeno. + * + * Se compara contra ORIGIN porque el navegador ya lo validó: adapter-node rechaza los POST cuyo + * Origin no cuadre, así que si la pantalla responde, ORIGIN es el origen real del panel. + */ +function panelUrlOriginMismatch(): string | null { + const declared = (env.PANEL_PUBLIC_URL || '').trim(); + const origin = (env.ORIGIN || '').trim(); + if (!declared || !origin) return null; + + let declaredUrl: URL; + let originUrl: URL; + try { + declaredUrl = new URL(declared); + originUrl = new URL(origin); + } catch { + return `PANEL_PUBLIC_URL no es una URL válida (${declared}): el agente no sabría a dónde reportar.`; + } + // `host` incluye el puerto, que es justo lo que se omitió en producción. + if (declaredUrl.host === originUrl.host) return null; + return ( + `PANEL_PUBLIC_URL (${declaredUrl.origin}) no coincide con el origen del panel ` + + `(${originUrl.origin}). Es la URL que se siembra en el agente al instalar y al actualizar, ` + + 'así que apuntaría a otro servicio. Corrige el puerto o comenta PANEL_PUBLIC_URL para que ' + + 'tome ORIGIN.' + ); +} + export const load: PageServerLoad = async ({ cookies }) => { const currentUser = await requireAdmin(cookies); let dbWarning: string | null = null; @@ -137,10 +183,11 @@ export const load: PageServerLoad = async ({ cookies }) => { // Avisos de configuración: sin estos datos la pantalla se ve bien pero no puede operar. const configWarnings: string[] = []; - if (!isGiteaConfigured()) { - configWarnings.push( - 'GITEA_TOKEN no está configurado en el panel: no se puede sincronizar ni descargar versiones.' - ); + // Cubre el token ausente y el mal capturado. Antes solo se avisaba del ausente, así que un + // valor con los `<>` de la plantilla solo se manifestaba como un 401 al presionar Sincronizar. + const tokenProblem = giteaTokenProblem(); + if (tokenProblem) { + configWarnings.push(`${tokenProblem}: no se puede sincronizar ni descargar versiones.`); } if (!panelApiToken()) { configWarnings.push( @@ -152,6 +199,10 @@ export const load: PageServerLoad = async ({ cookies }) => { 'PANEL_PUBLIC_URL (u ORIGIN) no está configurado: el agente no sabría a qué URL reportar.' ); } + const originMismatch = panelUrlOriginMismatch(); + if (originMismatch) { + configWarnings.push(originMismatch); + } return { currentUser, @@ -326,6 +377,104 @@ export const actions: Actions = { } }, + /** + * Fija a mano la ruta de instalación de un servidor. + * + * Un agente anterior a 1.1.0 no reporta su install_path, así que el panel cae al default de + * plataforma y ACTUALIZAR aborta cuando la instalación vive en otra carpeta. Sin esta captura + * ese servidor no se podría actualizar nunca: la ruta empieza a reportarse en 1.1.x, que es + * justo lo que no se puede instalar. En cuanto el servidor quede en 1.1.x su propio reporte + * sustituye este valor por la ruta real. + */ + setInstallPath: async ({ cookies, request }) => { + const currentUser = await requireAdmin(cookies); + const data = await request.formData(); + const targetId = parseId(data, 'target_id'); + const rawPath = data.get('install_path')?.toString().trim() ?? ''; + + if (!targetId) return fail(400, { error: 'Servidor inválido.' }); + if (!rawPath) return fail(400, { error: 'Captura la ruta de instalación.' }); + + let target: Awaited>[number] | undefined; + try { + target = (await listCrasTargetInventory()).find( + (t) => t.restore_target_id === targetId + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return fail(500, { error: `No se pudo leer el inventario de servidores: ${msg}` }); + } + if (!target) return fail(404, { error: 'El servidor de restauración ya no existe.' }); + + // Sin plataforma no hay con qué validar: las reglas de ruta son distintas en Windows y + // en Linux, y aplicar las equivocadas dejaría pasar una ruta que el instalador no puede + // usar. + if (!target.platform) { + return fail(422, { + error: + `No se conoce la plataforma de ${target.name}, y sin ella no se puede validar ` + + 'la ruta. Captura el campo SO en Servidores de Restauración.' + }); + } + if (!isSafeInstallPath(rawPath, target.platform)) { + return fail(422, { + error: + `"${rawPath}" no es una ruta válida para ${platformLabel(target.platform)}. ` + + 'Debe ser absoluta, sin comillas ni metacaracteres, sin ".." y sin rutas de red.' + }); + } + + // Se normaliza igual que effectiveInstallPath (cras-version.ts) para que el valor guardado + // sea comparable con el que reporte el agente después y no queden dos formas del mismo path. + const installPath = rawPath.replace(/[/\\]+$/, ''); + + // La raíz de una unidad pasa isSafeInstallPath pero no es un destino de instalación: ahí + // el instalador aplicaría ACLs y borrados recursivos sobre todo el disco. Solo se puede + // llegar por captura manual, porque el agente reporta la carpeta de su ejecutable. + if (!installPath || /^[A-Za-z]:$/.test(installPath)) { + return fail(422, { + error: 'La ruta no puede ser la raíz de la unidad; usa una carpeta.' + }); + } + + if (isInsideWorkFolder(installPath, [target.input_folder, target.processed_folder])) { + return fail(409, { + error: + `${installPath} está dentro de una carpeta de trabajo de ${target.name}. ` + + 'Ahí el agente tomaría su propio binario por un respaldo a procesar.' + }); + } + + try { + const updated = await setCloudRestoreInstallPath(target.name, installPath); + if (!updated) { + return fail(409, { + error: + `${target.name} no tiene ningún reporte del agente todavía, así que no hay ` + + 'instancia a la que fijarle la ruta. Espera a que reporte por primera vez.' + }); + } + logger.info({ + message: 'Ruta de instalación de CRAS fijada a mano', + context: { + target: target.name, + install_path: installPath, + previous: target.reported_install_path, + set_by: currentUser.username + } + }); + return { + success: + `Ruta de ${target.name} fijada en ${installPath}. ` + + 'Verifica antes de actualizar; cuando el servidor quede en 1.1.x el agente ' + + 'reportará su ruta y este valor se corrige solo.' + }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return fail(500, { error: `No se pudo guardar la ruta: ${msg}` }); + } + }, + /** * Instala o actualiza CRAS en un servidor. Bloqueante: puede tardar varios minutos * subiendo ~270 MB. La UI hace polling de cras_install_runs.steps para el progreso. @@ -400,6 +549,13 @@ export const actions: Actions = { 'sin ellos el agente instalado no sabría a dónde reportar.' }); } + // Se aborta antes de tocar el servidor: esta URL se escribe sobre el config/.env del + // destino también al ACTUALIZAR, así que dejarla pasar rompería un agente que hoy sí + // reporta. Es más barato corregir el .env del panel que reparar el del servidor. + const originMismatch = panelUrlOriginMismatch(); + if (originMismatch) { + return fail(500, { error: originMismatch }); + } try { const outcome = await installCrasOnTarget({ diff --git a/src/routes/versiones-cras/+page.svelte b/src/routes/versiones-cras/+page.svelte index e11e815..9df274c 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, @@ -308,6 +308,14 @@ }; } + /** + * Servidor cuya ruta de instalación se está capturando. Se muestra la ruta *efectiva* —la + * misma que resuelve el servidor con effectiveInstallPath— y no el valor crudo, porque lo que + * importa saber antes de actualizar es dónde va a buscar el instalador, no si la columna está + * en null. + */ + let editingPathTargetId = $state(null); + /** * `target.running_install_id` viene del load y no se refresca hasta el invalidateAll final, * así que la fila seguía ofreciendo Verificar/Instalar durante toda la instalación y se podía @@ -723,6 +731,79 @@ {target.ssh_username}@{target.ssh_host} {/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)} -- 2.49.1 From 5b83a57d183b901bffa736e67532f39e03ec1e76 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 30 Jul 2026 12:25:01 -0600 Subject: [PATCH 2/8] fix(cras-install): dejar de rechazar el layout normal de carpetas de trabajo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isInsideWorkFolder miraba la relación en los dos sentidos, así que también rechazaba que las carpetas de trabajo colgaran de la de instalación. Ese 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. Efecto: ninguna instalación ni actualización podía pasar, en Linux ni en Windows. Fallaba en resolveInstallPath —antes de abrir el run, así que no quedaba nada en la bitácora— con un mensaje que decía justo lo contrario de lo que ocurría: "la ruta está dentro de una carpeta de trabajo". Solo se libraban las instalaciones fuera de la ruta por omisión, por accidente del nombre. Se conserva el peligro que el guard existe para atajar: el binario dentro de Entrada/Procesados, donde el agente lo tomaría por un respaldo a procesar. La función no tenía ninguna prueba. Se agregan las dos direcciones, el caso exacto, la normalización de separador/caja/barra final y el prefijo que no es de carpeta (/srv/entradas vs /srv/entrada). Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/cras-version.test.ts | 59 ++++++++++++++++++++++++++++++++++++ src/lib/cras-version.ts | 10 +++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/lib/cras-version.test.ts b/src/lib/cras-version.test.ts index 9941016..3b8f4cb 100644 --- a/src/lib/cras-version.test.ts +++ b/src/lib/cras-version.test.ts @@ -12,6 +12,7 @@ import { effectivePlatform, installPlatformVerdict, isCrasPlatform, + isInsideWorkFolder, isNewer, isValidVersion, osToPlatform, @@ -201,3 +202,61 @@ 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); + }); +}); diff --git a/src/lib/cras-version.ts b/src/lib/cras-version.ts index 5457ab1..8914083 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,7 +214,7 @@ 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}/`); }); } -- 2.49.1 From dcba1e0a878bcefccbb301b21c2c0fc638ef7a27 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 30 Jul 2026 13:24:17 -0600 Subject: [PATCH 3/8] =?UTF-8?q?feat(cras-install):=20instalaci=C3=B3n=20si?= =?UTF-8?q?n=20privilegios=20y=20diagn=C3=B3stico=20preciso=20de=20elevaci?= =?UTF-8?q?=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El instalador exigía root o `sudo -n` (NOPASSWD), así que una cuenta con sudo CON contraseña —el caso de los servidores Linux— no podía actualizarse desde el panel. La contraseña guardada no ayudaba: se usa para autenticar SSH y a sudo nunca se le entrega, por decisión de diseño contra el antipatrón de AServers (`echo '{password}' | sudo -S`, que expone el secreto en argv). En vez de rodear esa decisión, se quita la necesidad de privilegios. El agente no necesita root para funcionar: su unit corre como el usuario que instala, y las rutas de data_folder las escribe SQL Server, no él. install.sh solo pedía privilegios por dos razones circunstanciales — el PREFIX por omisión en /opt y el unit en /etc/systemd/system. Modo nuevo `--user-service`: instala bajo el home y registra un unit de systemd de usuario. Para sobrevivir al cierre de sesión intenta lingering y, si el destino no lo permite, cae a @reboot en el crontab del usuario más un vigilante cada 5 min que sustituye al Restart=always. Con eso, al panel le bastan el usuario y la contraseña que ya tiene registrados. Además, la sonda de elevación pasa a ser compartida entre instalar y verificar (antes eran copias paralelas que ya diferían en la etiqueta) y distingue casos que se reportaban idénticos: - `requiretty` en el sudoers ya no se confunde con "sin privilegios": el remedio es el opuesto, porque agregar NOPASSWD no lo arregla. - Una regla NOPASSWD acotada a otros comandos se reporta como tal, con la lista. - Verificar informa si la ruta es escribible y si la instalación sin privilegios es viable en ese servidor, en vez de solo decir que faltan permisos. Dos bugs encontrados al probarlo, ambos con prueba: - `pgrep -f ` hacía match consigo mismo, porque la propia línea de cron del vigilante contiene esa ruta. Sin el ancla `^`, el vigilante creía que el agente corría y no lo rearrancaba nunca. - `expectedStepCount` comparaba solo con 'service', así que la barra de una instalación en modo usuario habría llegado al 100 % con un paso pendiente. `resolveLinuxPrivilege` no tenía ninguna prueba; se agregan seis. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/cras-install-progress.test.ts | 11 +- src/lib/cras-install-progress.ts | 12 +- src/lib/server/cras-install.test.ts | 96 +++++++++++- src/lib/server/cras-install.ts | 170 ++++++++++++++++++---- src/lib/server/cras-verify.ts | 76 ++++++++-- src/routes/versiones-cras/+page.server.ts | 13 +- src/routes/versiones-cras/+page.svelte | 16 +- 7 files changed, 343 insertions(+), 51 deletions(-) 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/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index f7febd5..de0271b 100644 --- a/src/lib/server/cras-install.test.ts +++ b/src/lib/server/cras-install.test.ts @@ -7,7 +7,13 @@ * hace `echo '{password}' | sudo -S ...`. */ import { describe, expect, it, vi } from 'vitest'; -import { execRemote, psEncoded, shQuote, InstallError } from './cras-install'; +import { + execRemote, + probeLinuxElevation, + psEncoded, + shQuote, + InstallError +} from './cras-install'; /** Cliente SFTP falso que expone un `client.exec` controlable, como el real. */ function fakeSftp(handler: (command: string) => { code?: number; stdout?: string; stderr?: string }) { @@ -200,3 +206,91 @@ describe('no fuga de secretos en los comandos remotos', () => { expect(decoded).toContain('-PanelEnvFile'); }); }); + +/** + * 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/); + } + }); +}); diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index 31e2397..d180259 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 { @@ -495,7 +499,21 @@ async function installLinux( // un Windows (o en un WSL dentro de un Windows) hay que abortar aquí y no tras subir 270 MB. const systemEvidence = await assertSystemMatches(sftp, 'linux'); - const privileged = await resolveLinuxPrivilege(sftp, target.ssh_username); + // El modo `user-service` instala en el home con un unit de usuario, así que no necesita + // ninguna elevación. Los modos de sistema sí: ahí la falta de privilegios se aborta aquí, + // antes de transferir 270 MB, porque fallar después es desperdicio y deja basura en /tmp. + const autostartMode = request.autostart ?? 'service'; + const privileged = await probeLinuxElevation(sftp); + if (autostartMode !== 'user-service' && !privileged.prefix && privileged.elevation !== 'root') { + throw new InstallError( + 409, + `El usuario '${target.ssh_username}' no puede elevar privilegios en este servidor: ` + + `${privileged.detail} Para instalar en ${installPath} y registrar el servicio de ` + + 'sistema hacen falta. Alternativas: usar una cuenta root, dar NOPASSWD a ese ' + + 'usuario, o elegir el arranque "Servicio de usuario", que instala en el home y no ' + + 'necesita privilegios.' + ); + } await appendInstallStep( runId, 'precondiciones', @@ -599,37 +617,83 @@ async function installLinux( } } -interface LinuxPrivilege { - /** Prefijo a poner delante de los comandos que requieren root. */ +/** + * Vías de elevación en Linux, de mejor a peor. + * + * `requiretty` se distingue de `ninguna` porque el remedio es el opuesto: con requiretty, + * agregar NOPASSWD no sirve de nada —sudo rechaza antes de mirar la política— y el consejo de + * "configura NOPASSWD" manda al operador a hacer algo inútil. Es un fallo de sudo, no de permisos. + */ +export type LinuxElevation = 'root' | 'sudo-sin-password' | 'requiretty' | 'ninguna'; + +export interface LinuxPrivilege { + /** Prefijo a poner delante de los comandos que requieren root. Vacío si no hay elevación. */ prefix: string; 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.' + }; } async function verifyLinuxDeployment( @@ -637,12 +701,18 @@ 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 ): 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 stamp = await execRemote( + sftp, + `cat ${shQuote(`${installPath}/config/.version`)} 2>/dev/null` + ); const deployed = stamp.stdout.trim(); if (deployed && deployed !== release.version) { throw new InstallError( @@ -657,18 +727,56 @@ 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. + 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) { + // El ancla ^ es obligatoria: sin ella el `sh -c` que corre este mismo pgrep lleva la + // ruta en su propia línea de comandos y haría match consigo mismo, reportando vivo un + // agente que no arrancó. + const alive = await execRemote( + sftp, + `pgrep -f ${shQuote(`^${installPath}/CloudRestoreAS`)} >/dev/null && echo si || echo no` + ); + if (alive.stdout.trim() === 'si') { + await appendInstallStep( + runId, + 'verificar-servicio', + true, + 'proceso vivo (arranque por cron; systemd de usuario no disponible)' + ); + return; + } + } throw new InstallError( 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' + ); } // ============================================================================ @@ -835,7 +943,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.ts b/src/lib/server/cras-verify.ts index 07a7384..05640fe 100644 --- a/src/lib/server/cras-verify.ts +++ b/src/lib/server/cras-verify.ts @@ -17,7 +17,7 @@ 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, shQuote } from './cras-install'; import { listCrasTargetInventory } from './cras-releases'; import { DEFAULT_INSTALL_PATHS, effectiveInstallPath, type CrasPlatform } from '$lib/cras-version'; import { logger } from './logger'; @@ -504,20 +504,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' ) ); @@ -581,7 +626,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/routes/versiones-cras/+page.server.ts b/src/routes/versiones-cras/+page.server.ts index 4807810..6d5e7eb 100644 --- a/src/routes/versiones-cras/+page.server.ts +++ b/src/routes/versiones-cras/+page.server.ts @@ -49,6 +49,7 @@ import { 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) { @@ -491,8 +492,16 @@ export const actions: Actions = { 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'; + // 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 diff --git a/src/routes/versiones-cras/+page.svelte b/src/routes/versiones-cras/+page.svelte index 9df274c..d7a872a 100644 --- a/src/routes/versiones-cras/+page.svelte +++ b/src/routes/versiones-cras/+page.svelte @@ -1226,12 +1226,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}

-- 2.49.1 From c433e1c34e46072f5bfa8f3edf3759b2e0da4f15 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 30 Jul 2026 15:03:35 -0600 Subject: [PATCH 4/8] fix(versiones-cras): validar que la URL sembrada sea alcanzable y permitir instalar limpio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dos bloqueos que impedían avanzar con la instalación en Linux. 1. La validación de PANEL_PUBLIC_URL solo comparaba que coincidiera con ORIGIN, y eso deja pasar el peor caso: que AMBAS valgan localhost, que es justo lo que produce el compose de desarrollo. Ahí no avisaba nada y la instalación sembraba loopback en el config/.env del destino, donde localhost es el propio destino y el agente acabaría hablando consigo mismo. Ahora se valida lo que de verdad importa —que la URL sea alcanzable desde otra máquina— y solo después el desajuste con ORIGIN. El mensaje también dejaba un consejo peligroso: "comenta PANEL_PUBLIC_URL para que tome ORIGIN" solo vale si ORIGIN sirve para sembrar; con ORIGIN en localhost, seguirlo empeora las cosas. Ese consejo ahora es condicional. De paso se corrige el docstring, que afirmaba que un ORIGIN equivocado se habría manifestado por la protección CSRF de adapter-node. No es cierto: svelte.config.js tiene csrf.checkOrigin en false, así que el Origin de los POST nunca se valida y ese razonamiento llevaba a conclusiones falsas. 2. El formulario fuerza mode='update' en cuanto el servidor tiene versión instalada, y una actualización exige que en la ruta destino ya viva algo. Eso hacía imposible mover una instalación a otra carpeta —por ejemplo al home, para instalar sin privilegios—: abortaba con "no hay una instalación". Se agrega una confirmación explícita siguiendo el molde de platformAck: el ack lleva la RUTA confirmada y no un booleano, así que una casilla marcada deja de valer si después se cambia el destino, y el servidor revalida en vez de confiar en la UI. La casilla advierte además que el agente anterior sigue corriendo: los dos reportarían con el mismo instance_key y se pisarían la carpeta de entrada registrada, que es por donde el panel enruta los respaldos. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/cras-version.test.ts | 68 +++++++++++++++++++ src/lib/cras-version.ts | 62 +++++++++++++++++ src/routes/versiones-cras/+page.server.ts | 83 +++++++++++++++++++---- src/routes/versiones-cras/+page.svelte | 61 +++++++++++++++++ 4 files changed, 259 insertions(+), 15 deletions(-) diff --git a/src/lib/cras-version.test.ts b/src/lib/cras-version.test.ts index 3b8f4cb..7721d04 100644 --- a/src/lib/cras-version.test.ts +++ b/src/lib/cras-version.test.ts @@ -12,7 +12,9 @@ import { effectivePlatform, installPlatformVerdict, isCrasPlatform, + isCleanInstallConfirmed, isInsideWorkFolder, + unseedablePanelUrlReason, isNewer, isValidVersion, osToPlatform, @@ -260,3 +262,69 @@ describe('isInsideWorkFolder', () => { expect(isInsideWorkFolder('', ['/srv/entrada'])).toBe(false); }); }); + +/** + * URL del panel que se siembra en el agente. + * + * El aviso original solo comparaba que PANEL_PUBLIC_URL y ORIGIN coincidieran, y eso deja pasar + * el peor caso: que ambas valgan localhost, que es justo lo que produce el compose de desarrollo. + * Lo que hay que exigir es que la URL sea alcanzable desde OTRA máquina. + */ +describe('unseedablePanelUrlReason', () => { + it('rechaza loopback en todas sus formas, aunque coincida con ORIGIN', () => { + // Es el hueco que se está cerrando: en el servidor destino, localhost es ese servidor. + expect(unseedablePanelUrlReason('https://localhost:3000')).toMatch(/localhost/); + expect(unseedablePanelUrlReason('http://127.0.0.1:3000')).toMatch(/127\.0\.0\.1/); + expect(unseedablePanelUrlReason('http://127.1.2.3:3000')).not.toBeNull(); + expect(unseedablePanelUrlReason('http://[::1]:3000')).not.toBeNull(); + expect(unseedablePanelUrlReason('http://0.0.0.0:3000')).not.toBeNull(); + }); + + it('acepta una URL alcanzable, con y sin puerto', () => { + expect(unseedablePanelUrlReason('https://cpanel-a24.aduanasoft.com:3000')).toBeNull(); + // Sin puerto también pasa: el problema de esa es el desajuste con ORIGIN, que es un + // aviso distinto. Mezclar los dos haría imposible saber cuál corregir. + expect(unseedablePanelUrlReason('https://cpanel-a24.aduanasoft.com')).toBeNull(); + expect(unseedablePanelUrlReason('http://10.0.0.5:3000')).toBeNull(); + }); + + it('rechaza vacío, basura y protocolos que el agente no habla', () => { + expect(unseedablePanelUrlReason('')).toMatch(/no está configurada/); + expect(unseedablePanelUrlReason(null)).toMatch(/no está configurada/); + expect(unseedablePanelUrlReason('cpanel-a24.aduanasoft.com:3000')).not.toBeNull(); + expect(unseedablePanelUrlReason('ftp://cpanel-a24.aduanasoft.com')).toMatch(/ftp/); + }); +}); + +/** + * Confirmación de instalación limpia en otra ruta. + * + * Sin esto no se puede mover una instalación existente —por ejemplo al home, para instalar sin + * privilegios—: el panel pide `update` en cuanto hay versión instalada y assertExistingInstall + * exige que ya viva algo en la ruta destino. + */ +describe('isCleanInstallConfirmed', () => { + it('solo confirma si el ack es exactamente la ruta pedida', () => { + expect(isCleanInstallConfirmed('/home/srvmid_db/cras', '/home/srvmid_db/cras')).toBe(true); + expect(isCleanInstallConfirmed('/home/srvmid_db/cras', '/opt/cloudrestoreas')).toBe(false); + }); + + it('un ack de otra ruta NO sirve: la casilla no queda encendida si se cambia el destino', () => { + // Es la razón de que el ack lleve la ruta y no un booleano. + expect(isCleanInstallConfirmed('/home/otro/cras', '/home/srvmid_db/cras')).toBe(false); + expect(isCleanInstallConfirmed('/opt/cloudrestoreas', 'true')).toBe(false); + expect(isCleanInstallConfirmed('/opt/cloudrestoreas', 'on')).toBe(false); + }); + + it('tolera el separador final y los espacios, que no cambian la ruta', () => { + expect(isCleanInstallConfirmed('/home/srvmid_db/cras', '/home/srvmid_db/cras/')).toBe(true); + expect(isCleanInstallConfirmed('C:\\Aduanasoft\\CRAS', ' C:\\Aduanasoft\\CRAS\\ ')).toBe(true); + }); + + it('sin ack, o sin ruta pedida, nunca confirma', () => { + expect(isCleanInstallConfirmed('/opt/cloudrestoreas', null)).toBe(false); + expect(isCleanInstallConfirmed('/opt/cloudrestoreas', '')).toBe(false); + expect(isCleanInstallConfirmed('', '')).toBe(false); + expect(isCleanInstallConfirmed(null, null)).toBe(false); + }); +}); diff --git a/src/lib/cras-version.ts b/src/lib/cras-version.ts index 8914083..b2a8b2d 100644 --- a/src/lib/cras-version.ts +++ b/src/lib/cras-version.ts @@ -218,6 +218,68 @@ export function isInsideWorkFolder( }); } +/** Loopback en cualquiera de sus formas. `URL.hostname` devuelve IPv6 entre corchetes. */ +function isLoopbackHost(hostname: string): boolean { + const h = hostname.trim().toLowerCase().replace(/^\[|\]$/g, ''); + return h === 'localhost' || h === '::1' || h === '0.0.0.0' || /^127\./.test(h); +} + +/** + * Motivo por el que una URL del panel NO se puede sembrar en un agente, o null si sirve. + * + * Lo que importa no es que `PANEL_PUBLIC_URL` y `ORIGIN` coincidan, sino que la URL sea + * **alcanzable desde otra máquina**: se escribe en el config/.env del servidor destino, al + * instalar y también al actualizar. Comparar solo que coincidan deja pasar el peor caso — que + * ambas valgan `localhost`, que es lo que produce el compose de desarrollo—, y ahí cada agente + * acabaría hablando consigo mismo en su propio servidor. + */ +export function unseedablePanelUrlReason(url: string | null | undefined): string | null { + const value = String(url ?? '').trim(); + if (!value) return 'no está configurada'; + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return `no es una URL válida (${value})`; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return `usa el protocolo ${parsed.protocol.replace(':', '')} y el agente habla HTTP(S)`; + } + if (isLoopbackHost(parsed.hostname)) { + return `apunta a ${parsed.hostname}, que desde el servidor destino es ese mismo servidor`; + } + return null; +} + +/** Normaliza una ruta para compararla: sin separador final y sin espacios alrededor. */ +function normalizeInstallPath(path: string | null | undefined): string { + return String(path ?? '') + .trim() + .replace(/[/\\]+$/, ''); +} + +/** + * ¿El operador confirmó que quiere una instalación LIMPIA en esta ruta? + * + * Cuando un servidor ya tiene una versión instalada, el panel pide `update`, y una actualización + * exige que en la ruta destino ya viva una instalación (`assertExistingInstall`). Esa guarda es + * correcta —evita crear una segunda instalación con la configuración por omisión y dejar huérfano + * el .env personalizado—, pero bloquea el caso legítimo de mover la instalación a otra ruta, por + * ejemplo al home para instalar sin privilegios. + * + * El ack lleva la RUTA confirmada y no un booleano, igual que `installPlatformVerdict` lleva la + * plataforma: así una casilla que quedó marcada no sigue valiendo si después se cambia la ruta. + */ +export function isCleanInstallConfirmed( + requestedInstallPath: string | null | undefined, + ack: string | null | undefined +): boolean { + const requested = normalizeInstallPath(requestedInstallPath); + if (!requested) return false; + return normalizeInstallPath(ack) === requested; +} + /** Etiqueta legible para la UI. */ export function platformLabel(platform: string | null | undefined): string { if (platform === 'windows') return 'Windows'; diff --git a/src/routes/versiones-cras/+page.server.ts b/src/routes/versiones-cras/+page.server.ts index 6d5e7eb..9301e3b 100644 --- a/src/routes/versiones-cras/+page.server.ts +++ b/src/routes/versiones-cras/+page.server.ts @@ -35,10 +35,13 @@ import { type PrunePlan } from '$lib/server/cras-artifacts'; import { + effectiveInstallPath, installPlatformVerdict, + isCleanInstallConfirmed, isInsideWorkFolder, isSafeInstallPath, - platformLabel + platformLabel, + unseedablePanelUrlReason } from '$lib/cras-version'; import { setCloudRestoreInstallPath } from '$lib/server/controldesk-pg'; import { syncCrasReleasesFromGitea } from '$lib/server/cras-sync'; @@ -77,20 +80,41 @@ function panelApiToken(): string { } /** - * Aviso cuando PANEL_PUBLIC_URL apunta a un origen distinto del que sirve el panel. + * Problemas de la URL del panel que se siembra en el agente. * - * Esta URL se siembra en el config/.env del agente, y el instalador la reescribe en CADA + * Esta URL se escribe en el config/.env del destino, y el instalador la reescribe en CADA * actualización, no solo al instalar. Una URL equivocada no falla al capturarla: rompe el servidor * la próxima vez que se actualice, y de golpe —el agente deja de reportar y de resolver rutas de * restauración—. En producción le faltaba el puerto y el 443 de ese host lo sirve otro servicio, * así que todo lo que pidiera el agente habría caído en un 404 ajeno. * - * Se compara contra ORIGIN porque el navegador ya lo validó: adapter-node rechaza los POST cuyo - * Origin no cuadre, así que si la pantalla responde, ORIGIN es el origen real del panel. + * Se revisan DOS cosas distintas, en este orden: + * + * 1. Que la URL efectiva sea **sembrable** (alcanzable desde otra máquina). Es lo que de verdad + * rompe, y no se puede deducir de que coincida con ORIGIN. + * 2. Que PANEL_PUBLIC_URL y ORIGIN coincidan, como señal de que una de las dos está mal escrita. + * + * Ojo con una tentación: NO se puede suponer que ORIGIN es correcto porque la pantalla responde. + * `svelte.config.js` tiene `csrf.checkOrigin: false`, así que adapter-node no valida el Origin de + * los POST y un ORIGIN equivocado nunca se manifiesta por esa vía. */ -function panelUrlOriginMismatch(): string | null { +function panelUrlProblem(): string | null { const declared = (env.PANEL_PUBLIC_URL || '').trim(); const origin = (env.ORIGIN || '').trim(); + const effective = declared || origin; + + // Primero lo que de verdad rompe: que la URL sembrada no sea alcanzable desde otra máquina. + // Comparar solo que declared y origin coincidan dejaba pasar el peor caso —ambas en + // localhost, que es lo que produce el compose de desarrollo— sin decir nada. + const unseedable = unseedablePanelUrlReason(effective); + if (unseedable) { + const which = declared ? 'PANEL_PUBLIC_URL' : 'ORIGIN'; + return ( + `La URL que se sembrará en el agente (${which}) ${unseedable}. Se escribe en el ` + + 'config/.env del servidor destino al instalar y al actualizar, así que tiene que ser ' + + 'alcanzable DESDE ese servidor.' + ); + } if (!declared || !origin) return null; let declaredUrl: URL; @@ -103,11 +127,18 @@ function panelUrlOriginMismatch(): string | null { } // `host` incluye el puerto, que es justo lo que se omitió en producción. if (declaredUrl.host === originUrl.host) return null; + + // El consejo de "comenta PANEL_PUBLIC_URL y toma ORIGIN" solo vale si ORIGIN sirve para + // sembrar. Darlo a ciegas cuando ORIGIN es loopback empeora las cosas: sembraría localhost. + const originUnseedable = unseedablePanelUrlReason(origin); return ( `PANEL_PUBLIC_URL (${declaredUrl.origin}) no coincide con el origen del panel ` + `(${originUrl.origin}). Es la URL que se siembra en el agente al instalar y al actualizar, ` + - 'así que apuntaría a otro servicio. Corrige el puerto o comenta PANEL_PUBLIC_URL para que ' + - 'tome ORIGIN.' + 'así que apuntaría a otro servicio. ' + + (originUnseedable + ? `Y ORIGIN tampoco sirve para sembrar: ${originUnseedable}. Corrige AMBAS con una URL ` + + 'alcanzable desde los servidores de restauración, incluyendo el puerto.' + : 'Corrige el puerto, o comenta PANEL_PUBLIC_URL para que tome ORIGIN.') ); } @@ -200,9 +231,9 @@ export const load: PageServerLoad = async ({ cookies }) => { 'PANEL_PUBLIC_URL (u ORIGIN) no está configurado: el agente no sabría a qué URL reportar.' ); } - const originMismatch = panelUrlOriginMismatch(); - if (originMismatch) { - configWarnings.push(originMismatch); + const urlProblem = panelUrlProblem(); + if (urlProblem) { + configWarnings.push(urlProblem); } return { @@ -491,7 +522,7 @@ export const actions: Actions = { if (!targetId || !releaseId) { return fail(400, { error: 'Servidor o versión inválidos.' }); } - const mode = modeRaw === 'update' ? 'update' : 'install'; + const requestedMode = modeRaw === 'update' ? 'update' : 'install'; // Lista blanca explícita en vez de una cadena de ternarios: con cuatro modos, el patrón // anterior convertía cualquier valor no reconocido en 'service', que es justo el que // exige privilegios. Un formulario viejo pediría el modo más restrictivo sin quererlo. @@ -561,9 +592,31 @@ export const actions: Actions = { // Se aborta antes de tocar el servidor: esta URL se escribe sobre el config/.env del // destino también al ACTUALIZAR, así que dejarla pasar rompería un agente que hoy sí // reporta. Es más barato corregir el .env del panel que reparar el del servidor. - const originMismatch = panelUrlOriginMismatch(); - if (originMismatch) { - return fail(500, { error: originMismatch }); + const urlProblem = panelUrlProblem(); + if (urlProblem) { + return fail(500, { error: urlProblem }); + } + + // Instalación LIMPIA en otra ruta. Sin esto no se puede mover una instalación existente + // —por ejemplo al home, para instalar sin privilegios—: el formulario pide `update` en + // cuanto hay versión instalada, y una actualización exige que en la ruta destino ya viva + // algo (assertExistingInstall). El ack lleva la RUTA confirmada, no un booleano, así que + // una casilla marcada deja de valer si después se cambia el destino. + const resolvedPath = effectiveInstallPath(target.reported_install_path, target.platform); + const cleanInstall = + requestedMode === 'update' && + isCleanInstallConfirmed(resolvedPath, data.get('clean_install_ack')?.toString()); + const mode = cleanInstall ? 'install' : requestedMode; + if (cleanInstall) { + logger.warn({ + message: 'Instalación limpia confirmada por el operador sobre un servidor con versión previa', + context: { + target: target.name, + install_path: resolvedPath, + previous_version: target.installed_version, + confirmed_by: currentUser.username + } + }); } try { diff --git a/src/routes/versiones-cras/+page.svelte b/src/routes/versiones-cras/+page.svelte index d7a872a..d22b4c5 100644 --- a/src/routes/versiones-cras/+page.svelte +++ b/src/routes/versiones-cras/+page.svelte @@ -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 @@ -1144,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}
-- 2.49.1 From e5df730f626c7eb2b630d1ea876273396bdcdf37 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 30 Jul 2026 15:40:58 -0600 Subject: [PATCH 5/8] feat(cras-install): actualizar sin privilegios cuando no hay root ni sudo -n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En entornos donde no se usa root, ninguna de las dos vías que había servía: la elevación abortaba con 409 y `user-service` exige mover la instalación al home, que en un servidor con el agente ya instalado en /opt pide un paso privilegiado para retirar el unit viejo. Se agrega una tercera vía para ACTUALIZACIONES: dejar el unit como está y solo reemplazar el binario, que es lo único que una actualización necesita. Corre `install.sh --update-in-place` sin prefijo de elevación. Tres precondiciones, comprobadas por SSH ANTES de subir 270 MB, cada una con su propio motivo de rechazo porque cada una tiene un remedio distinto: - El directorio de instalación debe ser escribible por la cuenta SSH. Es el único permiso que hace falta: `install` desvincula el destino antes de crearlo, así que un binario en ejecución no es obstáculo (eso es cosa de `cp`). - El unit debe correr con ese mismo usuario. Si quedó con User=root —alguien instaló desde un `sudo -i`— la cuenta no puede señalizar el proceso. Un User vacío se trata como root, que es lo que hace systemd. - El unit debe tener Restart=always, que es quien vuelve a levantarlo. Sin eso, señalizarlo lo dejaría muerto. Y se rechaza si hay una restauración en curso, aquí y otra vez en el destino. Dos correcciones de robustez en la verificación posterior: - El sello config/.version se sondea en vez de leerse una vez. En esta vía se omite el bootstrap y el sello lo escribe el proceso al reiniciarse, así que durante unos segundos sigue teniendo la versión ANTERIOR: la lectura única convertía una actualización correcta en "reporta 1.0.0, se esperaba 1.1.1". - `systemctl is-active` puede devolver 'activating' justo tras el reinicio, así que la evidencia que manda es el proceso vivo con el binario nuevo. El código 75 (EX_TEMPFAIL) de install.sh se traduce a un 409 con el motivo real —"está restaurando, reintenta"— en vez del 502 genérico que hacía pensar que la instalación se había roto. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/server/cras-install.test.ts | 104 ++++++++++++++++ src/lib/server/cras-install.ts | 180 ++++++++++++++++++++++++---- 2 files changed, 263 insertions(+), 21 deletions(-) diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index de0271b..8cc9b07 100644 --- a/src/lib/server/cras-install.test.ts +++ b/src/lib/server/cras-install.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest'; import { execRemote, + probeInPlaceUpdate, probeLinuxElevation, psEncoded, shQuote, @@ -294,3 +295,106 @@ describe('probeLinuxElevation', () => { } }); }); + +/** + * 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 d180259..652510d 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -504,21 +504,37 @@ async function installLinux( // antes de transferir 270 MB, porque fallar después es desperdicio y deja basura en /tmp. const autostartMode = request.autostart ?? 'service'; const privileged = await probeLinuxElevation(sftp); - if (autostartMode !== 'user-service' && !privileged.prefix && privileged.elevation !== 'root') { - throw new InstallError( - 409, - `El usuario '${target.ssh_username}' no puede elevar privilegios en este servidor: ` + - `${privileged.detail} Para instalar en ${installPath} y registrar el servicio de ` + - 'sistema hacen falta. Alternativas: usar una cuenta root, dar NOPASSWD a ese ' + - 'usuario, o elegir el arranque "Servicio de usuario", que instala en el home y no ' + - 'necesita privilegios.' - ); + 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. @@ -591,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 = @@ -601,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, @@ -609,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. @@ -696,6 +731,87 @@ export async function probeLinuxElevation(sftp: SftpClient): Promise { + 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.` + }; + } + + // `systemctl show` se lee sin privilegios, a diferencia de start/stop. + const unit = await execRemote( + sftp, + 'systemctl show -p User -p Restart cloudrestoreas 2>/dev/null' + ); + const props = new Map( + unit.stdout + .split('\n') + .map((line) => line.trim().split('=')) + .filter((parts) => parts.length === 2) + .map(([k, v]) => [k, v] as const) + ); + // User vacío en systemd significa root: el unit no declaró User=. + const unitUser = props.get('User') || 'root'; + 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( sftp: SftpClient, runId: number, @@ -705,19 +821,36 @@ async function verifyLinuxDeployment( // 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 + 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(`${installPath}/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( @@ -733,6 +866,9 @@ async function verifyLinuxDeployment( // 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, @@ -745,7 +881,7 @@ async function verifyLinuxDeployment( // 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) { + 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ó. @@ -758,7 +894,9 @@ async function verifyLinuxDeployment( runId, 'verificar-servicio', true, - 'proceso vivo (arranque por cron; systemd de usuario no disponible)' + inPlaceUpdate + ? `proceso vivo con el binario nuevo (unit intacto, estado: ${state})` + : 'proceso vivo (arranque por cron; systemd de usuario no disponible)' ); return; } -- 2.49.1 From 76598065052c8623df64926d15bd2e6e88f77285 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 30 Jul 2026 16:16:48 -0600 Subject: [PATCH 6/8] =?UTF-8?q?fix(cras-verify):=20validar=20que=20el=20se?= =?UTF-8?q?rvicio=20pueda=20LEER=20su=20configuraci=C3=B3n,=20y=20loguear?= =?UTF-8?q?=20los=20401?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dos huecos de diagnóstico que hacían pasar por sano lo que no lo estaba. 1. El check `config` usaba `test -f`, que comprueba existencia y no lectura, así que salía verde exactamente en el caso roto: una instalación con sudo deja config/.env en 0600 de root mientras el unit corre como una cuenta común, que no puede leerlo. El agente no arranca y la pantalla decía "Configuración presente: ok". Ahora se comprueba lectura y, además, el dueño frente al User= del unit — porque la sonda entra con la cuenta SSH, que no siempre es la del servicio, y un `test -r` desde la sesión no responde por el agente. El check se renombra a "Configuración legible por el servicio", que es lo que de verdad mide, y falla nombrando a los dos usuarios para que el remedio sea obvio. El cálculo del bit de lectura octal sale a una función pura probada: 0600 no deja leer a nadie más que al dueño, se mira el bit 4 y no el valor (620 y 611 no son lectura), se ignora el dígito de setuid, y un modo ilegible NO se interpreta como permisivo — asumir lectura cuando stat devuelve '-' sería el error caro. La lectura de propiedades del unit se extrae a un helper compartido con el instalador. Ya hubo una divergencia por copiar esta lógica: la resolución de elevación existía duplicada y las dos pantallas acabaron diciendo cosas distintas del mismo servidor. 2. Ningún rechazo de token de servicio dejaba rastro: los seis endpoints solo logueaban el 500 de "token no configurado". Con el token rotado, todos los agentes quedan mudos —dejan de reportar versión, resolver rutas y registrar resultados— y desde el panel se ve igual que un agente apagado; además el trace_id que se le devuelve al agente no existía del lado servidor, así que era imposible correlacionar. Se agrega un helper que loguea el rechazo con trace_id, ruta e instancia, y distingue "sin header Authorization" de "token no coincide", que son un agente sin configurar y un token rotado: dos problemas con remedios distintos. El token NUNCA se registra, ni un fragmento suyo — es el mismo valor en todos los agentes, así que un prefijo en los logs ya acota el espacio de búsqueda. Hay prueba de eso. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/server/cras-install.ts | 48 +++++--- src/lib/server/cras-verify.test.ts | 40 ++++++- src/lib/server/cras-verify.ts | 71 +++++++++-- src/lib/server/service-auth.test.ts | 111 ++++++++++++++++++ src/lib/server/service-auth.ts | 33 ++++++ src/routes/api/restore/agent-sync/+server.ts | 8 +- .../api/restore/instance-config/+server.ts | 8 +- src/routes/api/restore/job-result/+server.ts | 8 +- .../api/restore/resolve-route/+server.ts | 10 +- .../api/restore/target-catalog/+server.ts | 8 +- src/routes/api/restore/target-for/+server.ts | 10 +- 11 files changed, 327 insertions(+), 28 deletions(-) create mode 100644 src/lib/server/service-auth.test.ts diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index 652510d..f1933fb 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -731,6 +731,38 @@ export async function probeLinuxElevation(sftp: SftpClient): 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. * @@ -765,20 +797,8 @@ export async function probeInPlaceUpdate( }; } - // `systemctl show` se lee sin privilegios, a diferencia de start/stop. - const unit = await execRemote( - sftp, - 'systemctl show -p User -p Restart cloudrestoreas 2>/dev/null' - ); - const props = new Map( - unit.stdout - .split('\n') - .map((line) => line.trim().split('=')) - .filter((parts) => parts.length === 2) - .map(([k, v]) => [k, v] as const) - ); - // User vacío en systemd significa root: el unit no declaró User=. - const unitUser = props.get('User') || 'root'; + const props = await readUnitProps(sftp, 'cloudrestoreas', ['User', 'Restart']); + const unitUser = unitUserOrRoot(props); if (unitUser !== sshUsername) { return { ok: false, 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 05640fe..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, probeLinuxElevation, 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 @@ -588,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á` ) ); 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); } -- 2.49.1 From d84b4ef5f161576ec4a754813a6a4f0ed63ceb21 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 31 Jul 2026 08:46:43 -0600 Subject: [PATCH 7/8] =?UTF-8?q?fix(cras-install):=20agotar=20la=20elevaci?= =?UTF-8?q?=C3=B3n=20y=20verificar=20de=20verdad=20el=20despliegue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instalar y Actualizar delegaban en el operador en cuanto algo no era ideal. Linux: probeLinuxElevation solo probaba `sudo -n`. Si sudo pedía contraseña, el panel declaraba "sin privilegios" y abortaba, aunque tuviera esa contraseña guardada y la estuviera usando para abrir la sesión SSH. La política que lo prohibía era más estricta de lo que su propio motivo exige: lo que hace inseguro el `echo '{pw}' | sudo -S` de AServers es que la contraseña acaba en el argv del `sh -c`, legible con `ps` por cualquier usuario del destino. Por el stdin del canal `exec` no pasa por ningún argv, ningún historial ni ningún proceso intermedio, y al no concatenarse a un comando tampoco permite inyección. Es la misma credencial con la que ya se autenticó la sesión, y no llega a install.sh: la consume el sudo que lo invoca. La cadena queda root -> sudo -n -> sudo -S -> actualización en sitio -> user-service. Windows: el sello config\.version se leía UNA sola vez. Como el bootstrap está acotado a 20s y desempacar un onefile de ~270 MB con Defender escaneando se pasa de largo, el sello conservaba la versión anterior y una actualización correcta fallaba con 502. En instalación nueva no se notaba (no hay sello previo), así que rompía solo las actualizaciones. Ahora reintenta, como ya hacía Linux. Y la verificación daba por buena cualquier tarea con un State no vacío. `Ready` es una tarea registrada que NO está corriendo: justo lo que se ve cuando el agente arrancó y murió. Ahora se comprueba el proceso vivo, y en todos los modos — antes se salía antes de mirar nada si el arranque no era 'service'. Las actualizaciones pasan por install.ps1 -UpdateInPlace, y su código 75 se traduce al 409 amable que ya tenía Linux. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/server/api-error.ts | 11 +- src/lib/server/cras-install.test.ts | 219 ++++++++++++++++-- src/lib/server/cras-install.ts | 342 ++++++++++++++++++++++++---- 3 files changed, 507 insertions(+), 65 deletions(-) diff --git a/src/lib/server/api-error.ts b/src/lib/server/api-error.ts index d182183..e8fc7b6 100644 --- a/src/lib/server/api-error.ts +++ b/src/lib/server/api-error.ts @@ -9,11 +9,20 @@ export function newTraceId(): string { return randomUUID(); } +/** + * Estados que el panel emite. Es una lista cerrada a propósito (Aduanasoft §5): si un error no + * encaja en ninguno, casi siempre es que está mal modelado, no que falte un código. + * + * Se exporta para que quien construya errores propios —como `AgentControlError`— tipe su estado + * con esto y el compilador lo valide donde se crea, en vez de descubrirlo al serializarlo. + */ +export type ApiErrorStatus = 400 | 401 | 403 | 404 | 409 | 422 | 500 | 502 | 503; + export function errorJson( // 502 para fallos de un servicio del que dependemos (p.ej. Gitea al sincronizar // versiones de CRAS): distinguirlo de 503 le dice al operador que el panel está bien y // el problema está aguas arriba. - code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 502 | 503, + code: ApiErrorStatus, message: string, traceId: string ) { diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index 8cc9b07..5eb79e8 100644 --- a/src/lib/server/cras-install.test.ts +++ b/src/lib/server/cras-install.test.ts @@ -1,36 +1,69 @@ /** * Instalador remoto de CloudRestoreAS: utilidades de ejecución y quoting. * - * La prueba más importante de este archivo es la de fuga de secretos: el token del panel - * NUNCA debe aparecer en un comando remoto, porque `ps` y el historial del servidor destino - * son legibles por otros usuarios. Es exactamente el defecto del instalador de AServers, que - * hace `echo '{password}' | sudo -S ...`. + * La prueba más importante de este archivo es la de fuga de secretos: ni el token del panel ni + * la contraseña SSH deben aparecer NUNCA en un comando remoto, porque `ps` y el historial del + * servidor destino son legibles por otros usuarios. Es exactamente el defecto del instalador de + * AServers, que hace `echo '{password}' | sudo -S ...`. + * + * Nótese qué prohíbe ese invariante y qué no: prohíbe el secreto en la LÍNEA DE COMANDOS, no + * elevar con contraseña. `sudo -S` alimentado por el stdin del canal cumple la regla, y es lo + * que permite instalar en /opt sin ir a tocar el sudoers del servidor. */ import { describe, expect, it, vi } from 'vitest'; + +// La verificación del despliegue asienta cada paso en cras_install_runs, que va a PostgreSQL. +// Aquí interesa la DECISIÓN (¿esto cuenta como desplegado?), no la bitácora, así que se +// sustituye el módulo entero en vez de levantar una base. +vi.mock('./cras-releases', () => ({ + appendInstallStep: vi.fn(async () => {}), + finishInstallRun: vi.fn(async () => {}), + getCrasReleaseById: vi.fn(async () => null), + listCrasTargetInventory: vi.fn(async () => []), + startInstallRun: vi.fn(async () => 1), + InstallRunConflictError: class extends Error {} +})); + import { execRemote, probeInPlaceUpdate, probeLinuxElevation, + probeWindowsElevation, psEncoded, shQuote, + verifyWindowsDeployment, 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 }) { const commands: string[] = []; + /** Lo que se escribió en el stdin de cada comando, en el mismo orden que `commands`. */ + const stdins: (string | null)[] = []; const sftp = { client: { exec(command: string, callback: (err: Error | null, stream?: unknown) => void) { commands.push(command); + const index = stdins.push(null) - 1; const result = handler(command); const listeners: Record void)[]> = {}; const stderrListeners: ((...args: unknown[]) => void)[] = []; + let ended = false; const stream = { on(event: string, fn: (...args: unknown[]) => void) { (listeners[event] ??= []).push(fn); return stream; }, + write(chunk: string) { + stdins[index] = (stdins[index] ?? '') + chunk; + return true; + }, + end() { + ended = true; + }, + get stdinClosed() { + return ended; + }, stderr: { on(_event: string, fn: (...args: unknown[]) => void) { stderrListeners.push(fn); @@ -53,7 +86,7 @@ function fakeSftp(handler: (command: string) => { code?: number; stdout?: string } } }; - return { sftp, commands }; + return { sftp, commands, stdins }; } describe('shQuote', () => { @@ -277,22 +310,170 @@ describe('probeLinuxElevation', () => { 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/); - } + it('sudo con la contraseña del panel: eleva cuando `sudo -n` no puede', async () => { + // El caso que dejaba tirado al operador: la cuenta SÍ está en el sudoers, solo que con + // contraseña. El panel ya la tiene —es con la que abrió la sesión— y ahora la usa. + const { sftp } = sftpFor({ + 'id -u': { code: 0, stdout: '1000' }, + 'sudo -n true': { code: 1, stderr: 'sudo: a password is required' }, + "sudo -S -k -p '' true": { code: 0 } + }); + const r = await probeLinuxElevation(sftp as never, 'secreto-del-panel'); + expect(r.elevation).toBe('sudo-con-password'); + expect(r.prefix).toContain('-S'); + // El prefijo y su stdin son inseparables: sin esto sudo se queda esperando. + expect(r.stdin).toBe('secreto-del-panel\n'); + }); + + it('la contraseña viaja por stdin y NUNCA por la línea de comandos', async () => { + // El invariante que hereda del defecto de AServers: `echo '{pw}' | sudo -S` deja la + // contraseña en el argv del `sh -c`, legible con `ps` por cualquier usuario del destino. + // Lo que se prohíbe es eso, no elevar con contraseña. + const PASSWORD = 'clave-que-no-debe-filtrarse'; + const { sftp, commands, stdins } = sftpFor({ + 'id -u': { code: 0, stdout: '1000' }, + 'sudo -n true': { code: 1, stderr: 'sudo: a password is required' }, + "sudo -S -k -p '' true": { code: 0 } + }); + const r = await probeLinuxElevation(sftp as never, PASSWORD); + + expect(commands.some((c) => c.includes(PASSWORD))).toBe(false); + expect(commands.some((c) => c.includes('echo'))).toBe(false); + expect(r.prefix).not.toContain(PASSWORD); + // Y sí llegó por el único canal admitido. + expect(stdins.some((s) => s?.includes(PASSWORD))).toBe(true); + }); + + it('requiretty NO gasta un intento de contraseña: sudo rechaza antes de mirar la política', async () => { + // Mandarle la contraseña ahí no arreglaría nada y sumaría un fallo al log de auth del + // destino, que es justo lo que dispara las alertas de fuerza bruta. + const { sftp, commands } = 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, 'secreto-del-panel'); + expect(r.elevation).toBe('requiretty'); + expect(commands.some((c) => c.includes('-S'))).toBe(false); + }); + + it('contraseña rechazada: lo dice, en vez de culpar a la falta de sudo', async () => { + // Los dos remedios son opuestos: meter al usuario en el sudoers, o corregir la + // contraseña registrada en el panel. Un mensaje genérico manda a hacer lo que no es. + const { sftp } = sftpFor({ + 'id -u': { code: 0, stdout: '1000' }, + 'sudo -n true': { code: 1, stderr: 'sudo: a password is required' }, + "sudo -S -k -p '' true": { code: 1, stderr: 'sudo: 1 incorrect password attempt' }, + 'sudo -n -l': { code: 1, stdout: '' } + }); + const r = await probeLinuxElevation(sftp as never, 'clave-vieja'); + expect(r.elevation).toBe('ninguna'); + expect(r.prefix).toBe(''); + expect(r.detail).toMatch(/contraseña SSH guardada en el panel/); + }); +}); + +/** + * Sonda de elevación en Windows. + * + * El estado que se añadió —y el motivo de estas pruebas— es "administrador con token filtrado": + * una cuenta que SÍ está en Administradores pero a la que OpenSSH entregó el token filtrado por + * UAC. Se veía igual que "no eres administrador", y el remedio es el opuesto: ahí la cuenta ya + * es la correcta y lo que falta es una política del servidor. + */ +describe('probeWindowsElevation', () => { + /** El comando va base64/UTF-16LE dentro de -EncodedCommand: hay que decodificarlo. */ + function fakePsSftp(reply: string) { + return fakeSftp((command) => { + const encoded = command.split('-EncodedCommand ')[1] ?? ''; + const script = Buffer.from(encoded, 'base64').toString('utf16le'); + // Se comprueba que la sonda mire el SID del grupo, que es lo único que distingue el + // token filtrado de una cuenta sin privilegios. + if (!script.includes('S-1-5-32-544')) return { code: 1, stdout: '' }; + return { code: 0, stdout: reply }; + }); + } + + it('administrador con token completo', async () => { + const { sftp } = fakePsSftp('admin'); + const r = await probeWindowsElevation(sftp as never); + expect(r.elevation).toBe('admin'); + expect(r.detail).toBe(''); + }); + + it('token filtrado por UAC: NO manda a cambiar de cuenta', async () => { + const { sftp } = fakePsSftp('token-filtrado'); + const r = await probeWindowsElevation(sftp as never); + expect(r.elevation).toBe('token-filtrado'); + // El remedio correcto es la política del servidor, no otra cuenta. + expect(r.detail).toMatch(/LocalAccountTokenFilterPolicy/); + expect(r.detail).toMatch(/No hay que cambiar de cuenta/); + }); + + it('cuenta sin privilegios: ahí sí hace falta otra cuenta', async () => { + const { sftp } = fakePsSftp('limitado'); + const r = await probeWindowsElevation(sftp as never); + expect(r.elevation).toBe('limitado'); + expect(r.detail).toMatch(/cuenta administradora/); + }); +}); + +/** + * Verificación del despliegue en Windows. + * + * El defecto que cubre: se daba por buena cualquier tarea con un `State` no vacío. `Ready` es una + * tarea REGISTRADA QUE NO ESTÁ CORRIENDO —exactamente lo que se ve cuando el agente arrancó y + * murió a los pocos segundos—, así que una actualización que dejaba el servidor sin restaurador + * se asentaba como completada y nadie se enteraba hasta que alguien pulsaba Verificar. + */ +describe('verifyWindowsDeployment', () => { + const RELEASE = { version: '1.1.2' } as never; + + /** Responde a cada script de PowerShell según lo que pregunte. */ + function fakeWindows(opts: { stamp: string; taskState: string; running: boolean }) { + return fakeSftp((command) => { + const encoded = command.split('-EncodedCommand ')[1] ?? ''; + const script = Buffer.from(encoded, 'base64').toString('utf16le'); + if (script.includes('config\\.version')) return { code: 0, stdout: opts.stamp }; + if (script.includes('Get-Process')) { + return { code: 0, stdout: opts.running ? 'corriendo:1' : 'detenido' }; + } + if (script.includes('Get-ScheduledTask')) return { code: 0, stdout: opts.taskState }; + return { code: 1, stdout: '' }; + }); + } + + it('tarea Ready con el proceso muerto NO cuenta como desplegado', async () => { + const { sftp } = fakeWindows({ stamp: '1.1.2', taskState: 'Ready', running: false }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') + ).rejects.toThrow(/el proceso no está corriendo/); + }); + + it('tarea corriendo y proceso vivo sí cuenta', async () => { + const { sftp } = fakeWindows({ stamp: '1.1.2', taskState: 'Running', running: true }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') + ).resolves.toBeUndefined(); + }); + + it('el proceso se comprueba también fuera del modo servicio', async () => { + // Antes se salía antes de mirar nada cuando el arranque no era 'service', así que + // actualizar con 'none' dejaba el agente muerto y el run quedaba en verde. + const { sftp, commands } = fakeWindows({ stamp: '1.1.2', taskState: '', running: false }); + await verifyWindowsDeployment(sftp as never, 1, RELEASE, 'none', 'C:\\CRAS'); + const scripts = commands.map((c) => + Buffer.from(c.split('-EncodedCommand ')[1] ?? '', 'base64').toString('utf16le') + ); + expect(scripts.some((s) => s.includes('Get-Process'))).toBe(true); + }); + + it('un sello con otra versión es un fallo, no un aviso', async () => { + const { sftp } = fakeWindows({ stamp: '1.1.1', taskState: 'Running', running: true }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') + ).rejects.toThrow(/reporta la versión 1\.1\.1/); }); }); diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index f1933fb..e61f4d5 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -12,11 +12,20 @@ * 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. - * - **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 password de sudo va por el stdin del canal `exec`, nunca por argv.** Es la distinción + * que importa, y la que el defecto de AServers no hace: lo que expone `echo '{pw}' | sudo -S` + * es que la contraseña acaba en la línea de comandos del `sh -c` que la ejecuta, visible con + * `ps` para cualquier usuario del destino. Escribirla en el stdin del canal no pasa por + * ningún argv, ningún historial ni ningún proceso intermedio, y al no concatenarse a un + * comando tampoco abre la puerta a inyección. Es la MISMA credencial con la que ya se + * autenticó la sesión SSH, así que no amplía la superficie: el residuo que queda es que vive + * en memoria del proceso del panel (ya vivía) y que sudo asienta el intento en el log de auth + * del destino. A cambio, instalar en /opt con servicio de sistema deja de exigir que alguien + * toque el sudoers del servidor. + * - **La elevación se agota en orden, y solo entonces se rinde:** root → `sudo -n` → + * `sudo -S` con la credencial del panel → actualización en sitio (que no eleva nada) → + * `user-service` bajo el home. Abortar antes de agotarlas devolvía al operador a la consola + * del servidor, que es justo lo que este módulo existe para evitar. * - **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 @@ -54,6 +63,13 @@ const EXEC_TIMEOUT_MS = 120_000; /** El bootstrap del binario corre con `timeout 20` del lado del destino; se da margen. */ const INSTALL_EXEC_TIMEOUT_MS = 300_000; +/** + * Prefijo de elevación cuando sudo pide contraseña. La contraseña NO va aquí: viaja por el stdin + * del canal (`-S`), y este prefijo es lo único que se concatena al comando, así que el argv del + * destino nunca la contiene. Ver `probeLinuxElevation` para el porqué de `-k` y `-p ''`. + */ +const SUDO_PASSWORD_PREFIX = "sudo -S -k -p '' "; + export class InstallError extends Error { constructor( public status: number, @@ -76,11 +92,19 @@ export interface ExecResult { * `ssh2-sftp-client` envuelve un `ssh2.Client` y lo expone en `.client`, pero sus tipos no * lo declaran; de ahí el cast. Se reutiliza esa conexión en lugar de abrir una segunda para * no autenticarse dos veces por instalación. + * + * `stdin` es el canal por el que viaja la contraseña de sudo (ver `probeLinuxElevation`). Es un + * parámetro aparte —y no algo que el llamador pueda concatenar a `command`— justamente para que + * un secreto no pueda acabar en la línea de comandos: el argv del destino es legible por + * cualquier usuario con `ps`. Nada de lo que se escriba aquí se registra en logs ni en los pasos + * del run. Se cierra el canal inmediatamente después de escribir: sudo consume su línea y el + * proceso real no hereda nada del resto. */ export function execRemote( sftp: SftpClient, command: string, - timeoutMs = EXEC_TIMEOUT_MS + timeoutMs = EXEC_TIMEOUT_MS, + stdin?: string ): Promise { const conn = (sftp as unknown as { client: SshClient }).client; if (!conn || typeof conn.exec !== 'function') { @@ -103,6 +127,12 @@ export function execRemote( reject(new InstallError(502, `No se pudo ejecutar en el destino: ${err.message}`)); return; } + // Solo se toca stdin cuando hay algo que mandar: cerrarlo siempre cambiaría el + // contrato de las decenas de comandos que hoy lo dejan abierto. + if (stdin !== undefined) { + stream.write(stdin); + stream.end(); + } let stdout = ''; let stderr = ''; let code = -1; @@ -500,15 +530,19 @@ async function installLinux( const systemEvidence = await assertSystemMatches(sftp, 'linux'); // 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. + // ninguna elevación. Los modos de sistema sí, y para ellos se agotan TODAS las vías — + // incluida la contraseña SSH que el panel ya tiene— antes de rendirse. Lo que no se hace es + // fallar después de transferir 270 MB: la decisión se toma aquí, con la sesión abierta. const autostartMode = request.autostart ?? 'service'; - const privileged = await probeLinuxElevation(sftp); - const canElevate = privileged.elevation === 'root' || privileged.elevation === 'sudo-sin-password'; + const privileged = await probeLinuxElevation(sftp, target.ssh_password); + const canElevate = + privileged.elevation === 'root' || + privileged.elevation === 'sudo-sin-password' || + privileged.elevation === 'sudo-con-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. + // Sin ninguna forma de elevar quedan dos vías antes de rendirse. `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') { @@ -621,8 +655,21 @@ async function installLinux( `${privileged.prefix}env PREFIX=${shQuote(installPath)} ` + `./install.sh${installerFlags} --panel-env-file ${shQuote(remoteEnv)}`; - await appendInstallStep(runId, 'ejecutar-instalador', true, `install.sh${installerFlags}`); - const install = await execRemote(sftp, installCmd, INSTALL_EXEC_TIMEOUT_MS); + await appendInstallStep( + runId, + 'ejecutar-instalador', + true, + `install.sh${installerFlags} (elevación: ${privileged.label || 'ninguna'})` + ); + // `privileged.stdin` solo trae algo en la vía `sudo -S`, y es lo que sudo consume antes + // de ceder el canal. install.sh no lee stdin —sus bucles `read` leen de archivo y su + // bootstrap se protege con ` { +export async function probeLinuxElevation( + sftp: SftpClient, + sshPassword?: string +): Promise { const id = await execRemote(sftp, 'id -u'); if (id.code === 0 && id.stdout.trim() === '0') { return { prefix: '', label: 'root', elevation: 'root', detail: '' }; @@ -715,6 +783,32 @@ export async function probeLinuxElevation(sftp: SftpClient): Promise { + const probe = await execRemote( + sftp, + psEncoded( + '$id=[Security.Principal.WindowsIdentity]::GetCurrent();' + + '$p=[Security.Principal.WindowsPrincipal]$id;' + + "$sid=New-Object Security.Principal.SecurityIdentifier 'S-1-5-32-544';" + + '$inGroup=[bool]($id.Groups | Where-Object { $_ -eq $sid });' + + 'if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){"admin"}' + + 'elseif($inGroup){"token-filtrado"}else{"limitado"}' + ) + ); + + switch (probe.stdout.trim()) { + case 'admin': + return { elevation: 'admin', label: 'administrador', detail: '' }; + case 'token-filtrado': + return { + elevation: 'token-filtrado', + label: 'administrador con token filtrado por UAC', + detail: + 'la cuenta SÍ pertenece a Administradores, pero la sesión SSH recibió el token ' + + 'filtrado por UAC, así que no puede mandar sobre una tarea que corre como ' + + 'SYSTEM. No hay que cambiar de cuenta: hay que permitir la elevación remota en ' + + 'el servidor, poniendo LocalAccountTokenFilterPolicy=1 (DWORD) en ' + + 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System.' + }; + default: + return { + elevation: 'limitado', + label: 'sin privilegios', + detail: + 'la cuenta SSH no pertenece a Administradores del destino, y la tarea programada ' + + 'corre como SYSTEM. Hace falta una cuenta administradora.' + }; + } +} + async function installWindows( sftp: SftpClient, runId: number, @@ -954,26 +1113,35 @@ async function installWindows( // --- Precondiciones ------------------------------------------------------ const systemEvidence = await assertSystemMatches(sftp, 'windows'); - const admin = await execRemote( + const privileged = await probeWindowsElevation(sftp); + const autostart = request.autostart ?? 'service'; + + // La elevación hace falta para MANDAR sobre la tarea, que corre como SYSTEM: registrarla en + // una instalación nueva, o pararla y arrancarla en una actualización. Se comprueba si existe + // en lugar de deducirlo del modo, porque un servidor puede tener la tarea puesta aunque esta + // corrida pida otro arranque, y ahí igual hay que poder detenerla. + const taskProbe = await execRemote( sftp, psEncoded( - '$p=[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent();' + - 'if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){"admin"}else{"limitado"}' + "if (Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue) " + + '{"si"} else {"no"}' ) ); - const autostart = request.autostart ?? 'service'; - if (autostart === 'service' && admin.stdout.trim() !== 'admin') { + const taskExists = taskProbe.stdout.trim() === 'si'; + const needsElevation = autostart === 'service' || taskExists; + + if (needsElevation && privileged.elevation !== 'admin') { throw new InstallError( 409, - 'El usuario SSH no es Administrador en el destino. La tarea programada ONSTART corre ' + - 'como SYSTEM y requiere elevación; usa una cuenta administradora o instala sin servicio.' + `No se puede operar la tarea programada de ${target.name}: ${privileged.detail}` ); } await appendInstallStep( runId, 'precondiciones', true, - `${systemEvidence}, privilegios=${admin.stdout.trim()}` + `${systemEvidence}, privilegios=${privileged.label}` + + (taskExists ? ', tarea CloudRestoreAS ya registrada' : '') ); if (request.mode === 'update') { @@ -1067,7 +1235,18 @@ async function installWindows( ); } - const flag = autostart === 'none' ? '' : autostart === 'service' ? ' -Service' : ' -Desktop'; + // Actualizar va SIEMPRE por -UpdateInPlace, sea cual sea el arranque configurado: no + // vuelve a registrar la tarea (así no pisa ajustes que el operador haya hecho sobre ella), + // se salta el bootstrap —una segunda instancia purgaría el Temp\ de la que está viva— y + // trae respaldo con reversión automática si el binario nuevo no levanta. + const inPlaceUpdate = request.mode === 'update'; + const flag = inPlaceUpdate + ? ' -UpdateInPlace' + : autostart === 'none' + ? '' + : autostart === 'service' + ? ' -Service' + : ' -Desktop'; await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`); const install = await execRemote( sftp, @@ -1077,6 +1256,17 @@ async function installWindows( ), INSTALL_EXEC_TIMEOUT_MS ); + // 75 = EX_TEMPFAIL, igual que en install.sh: hay una restauración en curso. Es un + // conflicto de estado, no un fallo; con el 502 genérico el operador salía a buscar una + // avería que no existe. + 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, @@ -1084,7 +1274,9 @@ async function installWindows( ); } - await verifyWindowsDeployment(sftp, runId, release, autostart, installPath); + await verifyWindowsDeployment( + sftp, runId, release, autostart, installPath, inPlaceUpdate + ); } finally { await cleanupStaging( sftp, @@ -1097,25 +1289,45 @@ async function installWindows( } } -async function verifyWindowsDeployment( +export async function verifyWindowsDeployment( sftp: SftpClient, runId: number, release: CrasRelease, autostart: AutostartMode, - prefix: string + prefix: string, + inPlaceUpdate = false ): Promise { - const stamp = await execRemote( - sftp, - psEncoded( - `if (Test-Path '${prefix}\\config\\.version') ` + - `{ Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw }` - ) - ); - const deployed = stamp.stdout.trim(); + // El sello config\.version lo escribe el bootstrap del binario. + // + // Al actualizar en sitio NO se corre el bootstrap, así que el sello lo escribe el propio + // agente al reiniciarse y durante unos segundos sigue teniendo la versión ANTERIOR. Leerlo + // una sola vez convertía una actualización correcta en un fallo "reporta 1.1.1, se esperaba + // 1.1.2" con el binario ya bien instalado y corriendo. Y en Windows pica más que en Linux: + // desempacar un onefile de ~270 MB con Defender escaneando cada archivo se pasa de largo del + // margen. Se sondea hasta que coincida, o hasta agotar los intentos. + let deployed = ''; + const stampAttempts = inPlaceUpdate ? 15 : 3; + for (let attempt = 0; attempt < stampAttempts; attempt += 1) { + if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2000)); + const stamp = await execRemote( + sftp, + psEncoded( + `if (Test-Path '${prefix}\\config\\.version') ` + + `{ Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw }` + ) + ); + 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( @@ -1125,7 +1337,31 @@ async function verifyWindowsDeployment( deployed ? `config\\.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)' ); - if (autostart !== 'service') return; + // El proceso vivo es la evidencia que manda, y se comprueba en TODOS los modos. Antes se + // salía aquí cuando el arranque no era 'service', así que una actualización que dejaba el + // servidor sin agente se asentaba como completada y nadie se enteraba hasta que alguien + // pulsaba Verificar. install.ps1 ya garantiza el rearranque; esto lo confirma desde fuera. + const proc = await execRemote( + sftp, + psEncoded( + "$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + + 'if ($p) { "corriendo:" + $p.Count } else { "detenido" }' + ) + ); + const running = proc.stdout.trim().startsWith('corriendo'); + + if (autostart !== 'service') { + // Sin tarea que consultar, el proceso es lo único que hay. En 'desktop' el agente arranca + // al iniciar sesión, así que no tenerlo vivo ahora no es un fallo; en el resto sí importa + // y se asienta tal cual para que la bitácora no mienta. + await appendInstallStep( + runId, + 'verificar-proceso', + running || autostart === 'desktop', + running ? 'CloudRestoreAS en ejecución' : 'CloudRestoreAS no está en ejecución' + ); + return; + } const task = await execRemote( sftp, @@ -1140,7 +1376,23 @@ async function verifyWindowsDeployment( 'La tarea programada CloudRestoreAS no quedó registrada en el destino.' ); } - await appendInstallStep(runId, 'verificar-servicio', true, `tarea CloudRestoreAS: ${state}`); + // `Ready` es una tarea registrada que NO está corriendo: es exactamente lo que se ve cuando + // el agente arrancó y murió a los pocos segundos. Aceptar cualquier estado no vacío hacía + // pasar por buena una instalación que dejaba el servidor sin restaurador. + if (!running) { + throw new InstallError( + 502, + `La tarea CloudRestoreAS quedó registrada (estado: ${state}) pero el proceso no está ` + + `corriendo, así que el agente no levantó. Revisa ${prefix}\\config\\logs en el ` + + 'servidor.' + ); + } + await appendInstallStep( + runId, + 'verificar-servicio', + true, + `tarea CloudRestoreAS: ${state}, proceso en ejecución` + ); } // ============================================================================ -- 2.49.1 From 026b109e8203956a2786b2a0bc1f33c4d08d2453 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 31 Jul 2026 08:47:17 -0600 Subject: [PATCH 8/8] feat(versiones-cras): arrancar el agente desde el panel, sin comandos a mano MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El diagnóstico sin acción no sirve de nada. Verificar sabía detectar "instalado pero detenido" y respondía con un comando de PowerShell y un botón de Copiar, dejándole al operador entrar al servidor a pegarlo — aunque el panel ya tuviera la sesión SSH, las credenciales y la elevación resueltas. `agent_could_apply` estaba documentado como "informativo: la ejecución remota no está implementada". Nuevo POST /versiones-cras/agent-start. Solo arranca: no instala, no actualiza y no detiene nada, así que el peor caso es un servidor que sigue como estaba. Es POST y no GET porque modifica el destino y una precarga del navegador no debe dispararlo. Al terminar se re-verifica en lugar de creerle al comando: lo que importa es el estado del servidor, no que no diera error. El comando sigue mostrándose, como referencia para quien prefiera hacerlo a mano. Cuando no hay tarea registrada no se ofrece el botón: ahí el remedio es reinstalar, y un botón que no puede funcionar es peor que ninguno. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/server/cras-agent-control.ts | 231 ++++++++++++++++++ src/lib/server/cras-verify.ts | 48 ++-- src/routes/versiones-cras/+page.svelte | 73 +++++- .../versiones-cras/agent-start/+server.ts | 73 ++++++ 4 files changed, 405 insertions(+), 20 deletions(-) create mode 100644 src/lib/server/cras-agent-control.ts create mode 100644 src/routes/versiones-cras/agent-start/+server.ts diff --git a/src/lib/server/cras-agent-control.ts b/src/lib/server/cras-agent-control.ts new file mode 100644 index 0000000..578f6b1 --- /dev/null +++ b/src/lib/server/cras-agent-control.ts @@ -0,0 +1,231 @@ +/** + * Arrancar el agente de un servidor de restauración, desde el panel. + * + * Existe porque el diagnóstico sin acción no sirve de nada. La pantalla de Verificar sabía + * detectar "instalado pero detenido" y respondía con un comando de PowerShell y un botón de + * *Copiar*, dejando al operador la tarea de entrar por RDP o SSH al servidor y pegarlo. El panel + * ya tiene la sesión SSH, las credenciales y la elevación resueltas: pedirle eso al operador era + * gratuito para nosotros y caro para él. + * + * Solo arranca. No instala, no actualiza, no reescribe configuración y no detiene nada: si algo + * sale mal, el peor caso es un servidor que sigue exactamente como estaba. + */ +import SftpClient from 'ssh2-sftp-client'; + +import { getRestoreTargetSsh } from './controldesk-pg'; +import { + execRemote, + probeLinuxElevation, + probeWindowsElevation, + psEncoded, + shQuote, + type LinuxPrivilege +} from './cras-install'; +import { probeRemoteSystem } from './cras-verify'; +import { listCrasTargetInventory } from './cras-releases'; +import { effectiveInstallPath, DEFAULT_INSTALL_PATHS } from '$lib/cras-version'; +import type { ApiErrorStatus } from './api-error'; +import { logger } from './logger'; + +const CONNECT_TIMEOUT_MS = 20_000; +/** Margen para que el agente aparezca en la tabla de procesos tras pedir el arranque. */ +const ALIVE_TIMEOUT_MS = 45_000; +const POLL_INTERVAL_MS = 2_000; + +export class AgentControlError extends Error { + constructor( + public status: ApiErrorStatus, + message: string + ) { + super(message); + this.name = 'AgentControlError'; + } +} + +export interface StartAgentOutcome { + ok: boolean; + /** Qué se hizo y con qué resultado, en una línea, para mostrar tal cual en la UI. */ + detail: string; +} + +/** + * Sondea hasta que el agente aparezca vivo, o hasta agotar el margen. + * + * Se espera de verdad en lugar de responder en cuanto el comando de arranque retorna: tanto + * `Start-ScheduledTask` como `systemctl start` vuelven enseguida, y el binario es un onefile de + * ~270 MB que tarda en desempacarse. Contestar "arrancado" ahí sería la misma mentira que + * cometía la verificación de la instalación. + */ +async function waitAlive(check: () => Promise): Promise { + const deadline = Date.now() + ALIVE_TIMEOUT_MS; + for (;;) { + if (await check()) return true; + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } +} + +async function startOnWindows(sftp: SftpClient): Promise { + const privileged = await probeWindowsElevation(sftp); + if (privileged.elevation !== 'admin') { + throw new AgentControlError( + 409, + `No se puede arrancar la tarea programada: ${privileged.detail}` + ); + } + + const start = await execRemote( + sftp, + psEncoded( + "$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + + 'if (-not $t) { Write-Output "sin-tarea"; exit 0 }; ' + + "Start-ScheduledTask -TaskName 'CloudRestoreAS'; Write-Output 'arrancada'" + ) + ); + if (start.stdout.trim() === 'sin-tarea') { + throw new AgentControlError( + 409, + 'En el servidor no hay una tarea programada CloudRestoreAS que arrancar. Reinstala ' + + 'desde el panel eligiendo el arranque de servicio.' + ); + } + if (start.code !== 0) { + throw new AgentControlError( + 502, + `Start-ScheduledTask falló: ${start.stderr || start.stdout || 'sin salida'}` + ); + } + + const alive = await waitAlive(async () => { + const proc = await execRemote( + sftp, + psEncoded( + "if (Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue) " + + '{"si"} else {"no"}' + ) + ); + return proc.stdout.trim() === 'si'; + }); + + return alive + ? { ok: true, detail: 'Tarea CloudRestoreAS arrancada y proceso en ejecución.' } + : { + ok: false, + detail: + 'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' + + 'config\\logs en el servidor: el agente está fallando al iniciar.' + }; +} + +/** + * Ruta donde vive el agente, para poder reconocer su proceso. + * + * El ancla `^` del patrón no es opcional: sin ella, el `sh -c` que corre el propio `pgrep` lleva + * la ruta en su línea de comandos y haría match consigo mismo, reportando vivo un agente que + * nunca arrancó. Es el mismo motivo por el que install.sh ancla su patrón. + */ +async function resolveLinuxPrefix(restoreTargetId: number): Promise { + const inventory = await listCrasTargetInventory(); + const row = inventory.find((t) => t.restore_target_id === restoreTargetId); + return effectiveInstallPath(row?.reported_install_path ?? null, 'linux') ?? DEFAULT_INSTALL_PATHS.linux; +} + +async function startOnLinux( + sftp: SftpClient, + privileged: LinuxPrivilege, + prefix: string +): Promise { + const pgrep = `pgrep -f ${shQuote(`^${prefix}/CloudRestoreAS`)} >/dev/null && echo si || echo no`; + const isAlive = async () => (await execRemote(sftp, pgrep)).stdout.trim() === 'si'; + + // Unit de sistema primero: es la instalación recomendada. Si no hay elevación, `prefix` va + // vacío y systemctl fallará solo, sin efectos: ahí se pasa al unit de usuario. + let how = ''; + const system = await execRemote( + sftp, + `${privileged.prefix}systemctl start cloudrestoreas`, + undefined, + privileged.stdin + ); + if (system.code === 0) { + how = `systemctl start cloudrestoreas (elevación: ${privileged.label || 'ninguna'})`; + } else { + // Instalación sin privilegios: el unit vive en el bus del propio usuario. XDG_RUNTIME_DIR + // va explícito porque un `exec` de SSH no es una sesión de login y no siempre lo trae. + const user = await execRemote( + sftp, + 'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user start cloudrestoreas' + ); + if (user.code !== 0) { + throw new AgentControlError( + 502, + 'No se pudo arrancar el servicio ni como unit de sistema ni como unit de usuario. ' + + `Sistema: ${system.stderr || system.stdout || 'sin salida'}. ` + + `Usuario: ${user.stderr || user.stdout || 'sin salida'}.` + ); + } + how = 'systemctl --user start cloudrestoreas'; + } + + const alive = await waitAlive(isAlive); + return alive + ? { ok: true, detail: `${how}: el agente está en ejecución.` } + : { + ok: false, + detail: + `${how} no devolvió error, pero el proceso no apareció. Revisa ` + + '`journalctl -u cloudrestoreas -n 50` en el servidor.' + }; +} + +/** + * Arranca el agente en el destino indicado. Lanza `AgentControlError` con el estado HTTP que + * corresponde cuando el servidor no está en condiciones de que se le pida esto. + */ +export async function startCrasAgent(restoreTargetId: number): Promise { + const target = await getRestoreTargetSsh(restoreTargetId); + if (!target) { + throw new AgentControlError( + 409, + 'El servidor no tiene credenciales SSH completas (host, usuario y contraseña).' + ); + } + + const sftp = new SftpClient(`cras-start-${restoreTargetId}`); + try { + await sftp.connect({ + host: target.ssh_host, + port: target.ssh_port, + username: target.ssh_username, + password: target.ssh_password, + readyTimeout: CONNECT_TIMEOUT_MS + }); + + const system = await probeRemoteSystem(sftp); + if (system.verdict === 'windows') { + return await startOnWindows(sftp); + } + if (system.verdict === 'linux') { + const privileged = await probeLinuxElevation(sftp, target.ssh_password); + const prefix = await resolveLinuxPrefix(restoreTargetId); + return await startOnLinux(sftp, privileged, prefix); + } + throw new AgentControlError( + 409, + `No se pudo determinar el sistema del destino (${system.verdict}). Evidencia: ` + + `${system.evidence}` + ); + } finally { + try { + await sftp.end(); + } catch (err) { + logger.warn({ + message: 'No se pudo cerrar la sesión SFTP de arranque del agente', + context: { + target: target.name, + error: err instanceof Error ? err.message : String(err) + } + }); + } + } +} diff --git a/src/lib/server/cras-verify.ts b/src/lib/server/cras-verify.ts index 1671dac..d965249 100644 --- a/src/lib/server/cras-verify.ts +++ b/src/lib/server/cras-verify.ts @@ -73,13 +73,18 @@ export interface VerifyResult { export interface Remediation { /** Descripción de para qué sirve. */ title: string; - /** Comando exacto a correr EN el servidor destino, para copiar y pegar. */ + /** Comando equivalente EN el servidor destino. Referencia y registro, no la vía principal. */ command: string | null; - /** Dónde correrlo. */ + /** Dónde correrlo, cuando hay que correrlo a mano. */ where: string; /** - * Si el agente podría aplicarlo por su cuenta (corre local, no necesita SSH). Hoy es - * informativo: la ejecución remota desde el panel no está implementada. + * Si el PANEL puede aplicarlo por sí mismo, por la sesión SSH que ya tiene. Cuando es `true` + * la UI ofrece un botón que llama a POST /versiones-cras/agent-start, y el `command` queda + * como referencia para quien quiera hacerlo a mano o auditar qué se ejecutó. + * + * Dejarlo en `false` significa que el remedio está fuera del alcance del panel —capturar + * credenciales, corregir el sudoers, una política de UAC del servidor—, no que no se haya + * implementado. */ agent_could_apply: boolean; notes: string[]; @@ -475,7 +480,11 @@ export async function verifyCrasTarget(restoreTargetId: number): Promise { const checks: VerifyCheck[] = []; const prefix = effectiveInstallPath(reportedInstallPath, 'linux') ?? DEFAULT_INSTALL_PATHS.linux; - // 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'; + // La sonda es la MISMA que usa el instalador, y recibe la MISMA contraseña. Antes había aquí + // una copia paralela, y ya diferían en la etiqueta: dos pantallas contradiciéndose sobre el + // mismo hecho. Omitir aquí la contraseña reabriría esa grieta —Verificar diría "sin + // privilegios" de un servidor donde Instalar sí puede elevar—, que es peor que el original + // porque el desacuerdo sería sobre si la instalación va a funcionar. + const elevation = await probeLinuxElevation(sftp, sshPassword); + const privileged = + elevation.elevation === 'root' || + elevation.elevation === 'sudo-sin-password' || + elevation.elevation === 'sudo-con-password'; checks.push( check( 'privilegios', @@ -704,8 +720,8 @@ async function inspectLinux( remediation: { title: 'Arrancar el servicio del agente', command: 'sudo systemctl start cloudrestoreas && sudo systemctl status cloudrestoreas', - where: 'En una terminal del servidor, con privilegios de root.', - agent_could_apply: false, + where: 'El panel puede hacerlo por la sesión SSH que ya tiene.', + agent_could_apply: true, notes: ['Para ver la causa: sudo journalctl -u cloudrestoreas -n 50'] } }; @@ -844,8 +860,12 @@ async function inspectWindows( remediation: { title: 'Arrancar el agente en el servidor', command: 'Start-ScheduledTask -TaskName CloudRestoreAS', - where: 'En PowerShell como Administrador, en el servidor.', - agent_could_apply: false, + // Sin tarea registrada no hay nada que arrancar: el remedio es reinstalar, y + // ofrecer un botón que no puede funcionar es peor que no ofrecerlo. + where: scheduled + ? 'El panel puede hacerlo por la sesión SSH que ya tiene.' + : 'Panel → Versiones CRAS → Instalar, con arranque de servicio.', + agent_could_apply: scheduled, notes: [ `Logs del agente: ${prefix}\\config\\logs`, scheduled ? '' : 'La tarea no está registrada: reinstala desde el panel con arranque de servicio.' diff --git a/src/routes/versiones-cras/+page.svelte b/src/routes/versiones-cras/+page.svelte index d22b4c5..a31d7d3 100644 --- a/src/routes/versiones-cras/+page.svelte +++ b/src/routes/versiones-cras/+page.svelte @@ -254,6 +254,9 @@ verifyResult = null; verifyError = null; copiedKey = null; + // Un resultado de arranque pertenece a la verificación que lo produjo. `startAgent` lo + // vuelve a poner tras re-verificar; cualquier otra verificación debe empezar limpia. + startAgentOutcome = null; try { const res = await fetch(`/versiones-cras/verify?targetId=${targetId}`); const body = await res.json(); @@ -269,6 +272,46 @@ } } + /** Arranque del agente pedido desde el panel: en curso, y cómo terminó. */ + let startingAgent = $state(false); + let startAgentOutcome = $state<{ ok: boolean; detail: string } | null>(null); + + /** + * Aplica el remedio "arrancar el agente" sin salir del panel. + * + * Al terminar se vuelve a verificar en lugar de creerle a la respuesta: lo que interesa es el + * estado del servidor, no que el comando no diera error. `startAgentOutcome` se guarda antes + * de re-verificar porque `verifyTarget` limpia el resultado anterior. + */ + async function startAgent(targetId: number) { + startingAgent = true; + startAgentOutcome = null; + try { + const res = await fetch('/versiones-cras/agent-start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetId }) + }); + const body = await res.json(); + if (!res.ok) { + startAgentOutcome = { + ok: false, + detail: body?.error?.message ?? `El panel respondió ${res.status}` + }; + } else { + startAgentOutcome = { ok: Boolean(body.ok), detail: String(body.detail ?? '') }; + } + } catch (e) { + startAgentOutcome = { ok: false, detail: e instanceof Error ? e.message : String(e) }; + } finally { + startingAgent = false; + } + + const outcome = startAgentOutcome; + await verifyTarget(targetId); + startAgentOutcome = outcome; + } + async function copyValue(key: string, value: string) { try { await navigator.clipboard.writeText(value); @@ -1044,17 +1087,35 @@ {/if} {#if r.agent_could_apply} -

- info - El agente de este servidor corre con privilegios suficientes para - aplicarlo por su cuenta. La remediación asistida desde el panel - todavía no está implementada — por ahora hay que correr el comando - en el servidor. + +

+ El panel lo hace por la sesión SSH que ya tiene; el comando de + arriba es el equivalente, por si prefieres correrlo a mano.

{/if}
{/if} + + {#if startAgentOutcome} +

+ {startAgentOutcome.detail} +

+ {/if} +

Sonda de solo lectura. No instala ni reinicia nada en el servidor.

diff --git a/src/routes/versiones-cras/agent-start/+server.ts b/src/routes/versiones-cras/agent-start/+server.ts new file mode 100644 index 0000000..50bfbce --- /dev/null +++ b/src/routes/versiones-cras/agent-start/+server.ts @@ -0,0 +1,73 @@ +/** + * POST /versiones-cras/agent-start { targetId } + * + * Arranca el agente en un servidor de restauración. Es el remedio de "instalado pero detenido" + * convertido en acción: antes esa pantalla entregaba un comando de PowerShell con un botón de + * *Copiar* y el operador tenía que entrar al servidor a pegarlo, aunque el panel ya tuviera la + * sesión SSH y las credenciales. + * + * A diferencia de /verify, esta ruta SÍ modifica el destino, así que es POST y no GET: un GET + * con efectos lo dispararía cualquier precarga del navegador. Lo único que hace es arrancar; no + * instala, no actualiza y no detiene nada. + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { startCrasAgent, AgentControlError } from '$lib/server/cras-agent-control'; +import { errorJson, newTraceId } from '$lib/server/api-error'; +import { logger } from '$lib/server/logger'; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const traceId = newTraceId(); + + const token = cookies.get('session_token'); + const session = token ? verifyToken(token) : null; + if (!session) return errorJson(401, 'Sesión no válida', traceId); + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.es_admin) { + return errorJson(403, 'Requiere permisos de administrador', traceId); + } + + let targetId = 0; + try { + const body = await request.json(); + targetId = Number(body?.targetId); + } catch { + return errorJson(400, 'Cuerpo JSON inválido', traceId); + } + if (!Number.isInteger(targetId) || targetId <= 0) { + return errorJson(400, 'targetId inválido', traceId); + } + + try { + const outcome = await startCrasAgent(targetId); + logger.info({ + trace_id: traceId, + message: 'Arranque del agente CRAS solicitado desde el panel', + context: { + target_id: targetId, + ok: outcome.ok, + detail: outcome.detail, + started_by: currentUser.username + } + }); + return json({ ...outcome, trace_id: traceId }); + } catch (err) { + if (err instanceof AgentControlError) { + logger.warn({ + trace_id: traceId, + message: 'No se pudo arrancar el agente CRAS', + context: { target_id: targetId, status: err.status, error: err.message } + }); + return errorJson(err.status, err.message, traceId); + } + const message = err instanceof Error ? err.message : String(err); + logger.error({ + trace_id: traceId, + message: 'Error arrancando el agente CRAS', + context: { target_id: targetId, error: message } + }); + return errorJson(500, `Error interno al arrancar el agente: ${message}`, traceId); + } +}; -- 2.49.1