From 84a4c5e7e0db9bd5032b231682e4bd48df0fe049 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 30 Jul 2026 20:30:04 +0000 Subject: [PATCH] feature/cras-instalacion-sin-privilegios (#20) Reviewed-on: https://git.aduanasoft.com/ADUANASOFT/PANEL_BASES_ANEXO24/pulls/20 Co-authored-by: hreyes Co-committed-by: hreyes --- .env.example | 17 ++ src/lib/cras-install-progress.test.ts | 11 +- src/lib/cras-install-progress.ts | 12 +- src/lib/cras-version.test.ts | 59 +++++++ src/lib/cras-version.ts | 10 +- src/lib/server/controldesk-pg.test.ts | 96 +++++++++++- src/lib/server/controldesk-pg.ts | 36 +++++ src/lib/server/cras-install.test.ts | 96 +++++++++++- src/lib/server/cras-install.ts | 178 +++++++++++++++++---- src/lib/server/cras-verify.ts | 76 +++++++-- src/lib/server/gitea-packages.test.ts | 94 ++++++++++- src/lib/server/gitea-packages.ts | 57 ++++++- src/routes/versiones-cras/+page.server.ts | 181 +++++++++++++++++++++- src/routes/versiones-cras/+page.svelte | 99 +++++++++++- 14 files changed, 952 insertions(+), 70 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/cras-install-progress.test.ts b/src/lib/cras-install-progress.test.ts index a33bdc0..716902d 100644 --- a/src/lib/cras-install-progress.test.ts +++ b/src/lib/cras-install-progress.test.ts @@ -44,12 +44,19 @@ describe('currentPhaseLabel', () => { }); describe('expectedStepCount', () => { - it('cuenta verificar-servicio solo en modo servicio', () => { - // `cras-install.ts` corta con `if (autostart !== 'service') return;` antes de ese paso. + it('cuenta verificar-servicio solo en los modos que dejan algo arrancado', () => { + // `cras-install.ts` corta antes de ese paso cuando el modo no arranca nada. expect(expectedStepCount('service')).toBe(11); expect(expectedStepCount('desktop')).toBe(10); expect(expectedStepCount('none')).toBe(10); }); + + it('el servicio de usuario también verifica, aunque no use privilegios', () => { + // Si esto se quedara comparando solo con 'service', la barra de una instalación sin + // privilegios llegaría al 100 % con un paso todavía por delante. + expect(expectedStepCount('user-service')).toBe(11); + expect(progressPercent(10, 'user-service', 'running')).toBe(91); + }); }); describe('progressPercent', () => { diff --git a/src/lib/cras-install-progress.ts b/src/lib/cras-install-progress.ts index 1059a8b..2b070b2 100644 --- a/src/lib/cras-install-progress.ts +++ b/src/lib/cras-install-progress.ts @@ -17,7 +17,12 @@ export interface InstallStepView { ok: boolean; } -export type AutostartMode = 'service' | 'desktop' | 'none'; +/** + * `user-service` instala bajo el home del usuario con un unit de systemd **de usuario**: no + * necesita root ni sudo, así que bastan el usuario y la contraseña SSH que el panel ya guarda. + * Los otros modos de servicio escriben en /opt y /etc/systemd/system, que sí exigen privilegios. + */ +export type AutostartMode = 'service' | 'user-service' | 'desktop' | 'none'; export type RunStatus = 'running' | 'completed' | 'failed'; /** @@ -87,7 +92,10 @@ export function currentPhaseLabel(steps: InstallStepView[]): string { /** Pasos esperados del camino feliz según el modo de arranque. */ export function expectedStepCount(autostart: AutostartMode): number { - return autostart === 'service' + // Los dos modos de servicio —de sistema y de usuario— emiten `verificar-servicio`; los que + // no dejan nada arrancado, no. Si esto se quedara comparando solo con 'service', la barra de + // una instalación en modo usuario llegaría al 100 % con un paso todavía por delante. + return autostart === 'service' || autostart === 'user-service' ? INSTALL_STEP_SEQUENCE.length : INSTALL_STEP_SEQUENCE.length - 1; } diff --git a/src/lib/cras-version.test.ts b/src/lib/cras-version.test.ts index 9941016..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}/`); }); } diff --git a/src/lib/server/controldesk-pg.test.ts b/src/lib/server/controldesk-pg.test.ts index 9e0365a..b14cf80 100644 --- a/src/lib/server/controldesk-pg.test.ts +++ b/src/lib/server/controldesk-pg.test.ts @@ -6,18 +6,24 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // vi.mock se iza al tope del archivo, así que las factories no pueden capturar variables // declaradas después: se usa vi.hoisted, igual que en api/alerts/send/server.test.ts. -const { clientQuery, clientRelease, connectMock } = vi.hoisted(() => { +const { clientQuery, clientRelease, connectMock, poolQuery } = vi.hoisted(() => { const clientQuery = vi.fn(); const clientRelease = vi.fn(); return { clientQuery, clientRelease, + poolQuery: vi.fn(), connectMock: vi.fn(async () => ({ query: clientQuery, release: clientRelease })) }; }); -vi.mock('$lib/server/db', () => ({ pgPool: { query: vi.fn(), connect: connectMock } })); +vi.mock('$lib/server/db', () => ({ pgPool: { query: poolQuery, connect: connectMock } })); -import { matchNodeRowFromBackupStem, deleteRestoreJobLogs } from './controldesk-pg'; +import { + matchNodeRowFromBackupStem, + deleteRestoreJobLogs, + setCloudRestoreInstallPath, + upsertCloudRestoreStatus +} from './controldesk-pg'; const NODES = [ { @@ -140,3 +146,87 @@ describe('deleteRestoreJobLogs', () => { expect(clientRelease).toHaveBeenCalledOnce(); }); }); + +/** + * Ruta de instalación de una instancia de CloudRestoreAS. + * + * Es de lo que depende que ACTUALIZAR encuentre el config/.env del destino en lugar de abortar. + * Un agente anterior a 1.1.0 no reporta su ruta, así que la captura del operador es el único + * camino, y tiene que sobrevivir a los reportes posteriores de ese agente viejo. + */ +describe('ruta de instalación de CloudRestoreAS', () => { + /** Devuelve solo los UPDATE/INSERT: ensure*Table dispara DDL que aquí no interesa. */ + function writeStatements(): string[] { + return poolQuery.mock.calls + .map((c) => String(c[0])) + .filter((sql) => /^\s*(UPDATE|INSERT)/i.test(sql)); + } + + beforeEach(() => { + poolQuery.mockReset(); + poolQuery.mockResolvedValue({ rows: [], rowCount: 1 }); + }); + + it('compara el instance_key con LOWER(TRIM(...)), igual que el join con el servidor', async () => { + // listCrasTargetInventory cruza instancia y servidor con + // LOWER(TRIM(instance_key)) = LOWER(TRIM(name)). Con una igualdad exacta aquí, un + // instance_key que difiera en la caja actualizaría 0 filas aunque la pantalla sí los + // hubiera cruzado, y el operador vería "no hay reporte" sobre una fila que existe. + await setCloudRestoreInstallPath('Principal', 'C:\\Aduanasoft\\CloudRestoreAS-win'); + const [sql] = writeStatements(); + expect(sql).toMatch(/LOWER\(TRIM\(instance_key\)\)\s*=\s*LOWER\(TRIM\(\$2\)\)/i); + }); + + it('pasa la ruta como parámetro, nunca interpolada en el SQL', async () => { + const path = 'C:\\Aduanasoft\\CloudRestoreAS-win'; + await setCloudRestoreInstallPath('Principal', path); + const [sql] = writeStatements(); + expect(sql).not.toContain(path); + expect(poolQuery.mock.calls.at(-1)?.[1]).toEqual([path, 'Principal']); + }); + + it('no mueve reported_at: esa columna dice cuándo reportó el AGENTE', async () => { + await setCloudRestoreInstallPath('Principal', '/opt/cloudrestoreas'); + expect(writeStatements()[0]).not.toMatch(/reported_at/i); + }); + + it('devuelve false si no existe esa instancia, para no fingir que se guardó', async () => { + poolQuery.mockResolvedValue({ rows: [], rowCount: 0 }); + expect(await setCloudRestoreInstallPath('Inexistente', '/opt/cloudrestoreas')).toBe(false); + }); + + it('devuelve true cuando actualizó la fila', async () => { + expect(await setCloudRestoreInstallPath('Principal', '/opt/cloudrestoreas')).toBe(true); + }); + + /** + * El COALESCE del upsert es la pieza que sostiene todo el arreglo: el agente 1.0.0 reporta + * cada ciclo con install_path en null, y si eso pisara la columna, la ruta capturada duraría + * hasta el siguiente reporte y ACTUALIZAR volvería a abortar sin explicación. + */ + it('un reporte sin ruta NO borra la que ya estaba guardada', async () => { + await upsertCloudRestoreStatus({ + inputFolder: '/srv/entrada', + hostName: 'srvmid', + appVersion: '1.0.0', + installPath: null, + instanceKey: 'Principal' + }); + const [sql] = writeStatements(); + expect(sql).toMatch(/install_path\s*=\s*COALESCE\(EXCLUDED\.install_path/i); + expect(sql).not.toMatch(/install_path\s*=\s*EXCLUDED\.install_path\s*,/i); + }); + + it('un reporte CON ruta sí la sustituye', async () => { + await upsertCloudRestoreStatus({ + inputFolder: '/srv/entrada', + hostName: 'srvmid', + appVersion: '1.1.1', + installPath: '/opt/cloudrestoreas', + instanceKey: 'Principal' + }); + // Con EXCLUDED no nulo, el COALESCE resuelve al valor reportado: lo que se comprueba es + // que la ruta viaje como parámetro hasta el upsert y no se pierda en el camino. + expect(poolQuery.mock.calls.at(-1)?.[1]).toContain('/opt/cloudrestoreas'); + }); +}); diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts index 4c7fd6e..a447a13 100644 --- a/src/lib/server/controldesk-pg.ts +++ b/src/lib/server/controldesk-pg.ts @@ -1111,6 +1111,42 @@ export async function upsertCloudRestoreStatus(row: { ); } +/** + * Fija a mano la ruta de instalación de una instancia. Devuelve false si no existe esa fila. + * + * Un agente anterior a 1.1.0 no reporta install_path, así que el panel cae al default de + * plataforma y una ACTUALIZACIÓN aborta cuando la instalación vive en otra carpeta. Capturarla + * es la única forma de romper ese huevo-y-gallina: para que el panel sepa la ruta el agente + * tendría que estar en 1.1.x, y para llegar a 1.1.x hay que poder actualizarlo. + * + * No reusa upsertCloudRestoreStatus porque ese exige input_folder/processed_folder y los pisaría + * con lo que trajera el formulario. Y solo actualiza filas existentes: sin un reporte previo no + * hay instance_key al que colgar el valor, y un INSERT tendría que inventar el resto de columnas. + * + * El COALESCE de ese mismo upsert es lo que hace que este valor sobreviva a los reportes sin ruta + * del agente viejo; cuando el servidor quede en 1.1.x, su reporte lo sustituye por la ruta real. + * + * reported_at NO se toca: significa cuándo reportó el agente, y moverlo por una captura del + * operador falsearía la columna "Último reporte" de la pantalla. + */ +export async function setCloudRestoreInstallPath( + instanceKey: string, + installPath: string +): Promise { + await ensureCloudRestoreStatusTable(); + // Se compara con LOWER(TRIM(...)) igual que el join instancia↔servidor de + // listCrasTargetInventory: con una igualdad exacta, un instance_key que difiera del nombre + // del servidor en la caja o en espacios actualizaría 0 filas aunque la pantalla sí los + // hubiera cruzado, y el operador vería "no hay reporte" sobre una fila que sí existe. + const r = await pgPool.query( + `UPDATE ${qCloudRestoreStatus()} + SET install_path = $1 + WHERE LOWER(TRIM(instance_key)) = LOWER(TRIM($2))`, + [installPath, instanceKey] + ); + return (r.rowCount ?? 0) > 0; +} + /** Inserta un registro de bitácora reportado por CloudRestoreAS. */ export async function insertRestoreJobLog(row: { filename: string; diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index f7febd5..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 406b6a5..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 { @@ -423,7 +427,9 @@ async function resolveInstallPath( // Esa comprobación se hace con la sesión abierta, en assertExistingInstall(). if (request.mode === 'update' && !reportedInstallPath) { logger.warn({ - message: 'Actualización sin ruta reportada por el agente; se usará el default', + message: + 'Actualización sin ruta conocida (el agente no la reporta y no se capturó a mano); ' + + 'se usará el default', context: { target: target.name, install_path: resolved } }); } @@ -455,7 +461,9 @@ async function assertExistingInstall( `Se pidió ACTUALIZAR pero en ${installPath} no hay una instalación (falta config/.env). ` + (reportedPath ? `El agente reportó ${reportedPath}. ` - : 'El agente no ha reportado su ruta. ') + + : 'El agente no ha reportado su ruta —solo lo hace desde 1.1.0—, así que se usó la ' + + 'de omisión. Si la instalación vive en otra carpeta, captúrala con el lápiz que ' + + 'está junto al nombre del servidor y vuelve a intentar. ') + 'Se aborta para no crear una segunda instalación con la configuración por omisión ' + 'y dejar huérfano el .env personalizado. Si es una instalación nueva, usa Instalar.' ); @@ -491,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', @@ -595,37 +617,83 @@ async function installLinux( } } -interface LinuxPrivilege { - /** Prefijo a poner delante de los comandos que requieren root. */ +/** + * Vías de elevación en Linux, de mejor a peor. + * + * `requiretty` se distingue de `ninguna` porque el remedio es el opuesto: con requiretty, + * agregar NOPASSWD no sirve de nada —sudo rechaza antes de mirar la política— y el consejo de + * "configura NOPASSWD" manda al operador a hacer algo inútil. Es un fallo de sudo, no de permisos. + */ +export type LinuxElevation = 'root' | 'sudo-sin-password' | 'requiretty' | 'ninguna'; + +export interface LinuxPrivilege { + /** Prefijo a poner delante de los comandos que requieren root. Vacío si no hay elevación. */ prefix: string; 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( @@ -633,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( @@ -653,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' + ); } // ============================================================================ @@ -831,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/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..6d5e7eb 100644 --- a/src/routes/versiones-cras/+page.server.ts +++ b/src/routes/versiones-cras/+page.server.ts @@ -34,10 +34,22 @@ 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 type { AutostartMode } from '$lib/cras-install-progress'; import { logger } from '$lib/server/logger'; async function requireAdmin(cookies: import('@sveltejs/kit').Cookies) { @@ -64,6 +76,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 +184,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 +200,10 @@ export const load: PageServerLoad = async ({ cookies }) => { 'PANEL_PUBLIC_URL (u ORIGIN) no está configurado: el agente no sabría a qué URL reportar.' ); } + const originMismatch = panelUrlOriginMismatch(); + if (originMismatch) { + configWarnings.push(originMismatch); + } return { currentUser, @@ -326,6 +378,104 @@ export const actions: Actions = { } }, + /** + * Fija a mano la ruta de instalación de un servidor. + * + * Un agente anterior a 1.1.0 no reporta su install_path, así que el panel cae al default de + * plataforma y ACTUALIZAR aborta cuando la instalación vive en otra carpeta. Sin esta captura + * ese servidor no se podría actualizar nunca: la ruta empieza a reportarse en 1.1.x, que es + * justo lo que no se puede instalar. En cuanto el servidor quede en 1.1.x su propio reporte + * sustituye este valor por la ruta real. + */ + setInstallPath: async ({ cookies, request }) => { + const currentUser = await requireAdmin(cookies); + const data = await request.formData(); + const targetId = parseId(data, 'target_id'); + const rawPath = data.get('install_path')?.toString().trim() ?? ''; + + if (!targetId) return fail(400, { error: 'Servidor inválido.' }); + if (!rawPath) return fail(400, { error: 'Captura la ruta de instalación.' }); + + let target: Awaited>[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. @@ -342,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 @@ -400,6 +558,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..d7a872a 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)} @@ -1145,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}