From d570c7dac2e56f4d144c5dce1e27827b27e9b925 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 30 Jul 2026 12:16:06 -0600 Subject: [PATCH 1/3] =?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/3] 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/3] =?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