From 2bbbeff3ed7609a564bf07141ef019a6dfbe39c1 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 31 Jul 2026 15:00:05 +0000 Subject: [PATCH] fix/instalacion-desatendida (#23) Reviewed-on: https://git.aduanasoft.com/ADUANASOFT/PANEL_BASES_ANEXO24/pulls/23 Co-authored-by: hreyes Co-committed-by: hreyes --- src/lib/server/api-error.ts | 11 +- src/lib/server/cras-agent-control.ts | 231 ++++++++++++ src/lib/server/cras-install.test.ts | 316 +++++++++++++++- src/lib/server/cras-install.ts | 342 +++++++++++++++--- src/lib/server/cras-verify.ts | 48 ++- src/routes/versiones-cras/+page.svelte | 73 +++- .../versiones-cras/agent-start/+server.ts | 73 ++++ 7 files changed, 1012 insertions(+), 82 deletions(-) create mode 100644 src/lib/server/cras-agent-control.ts create mode 100644 src/routes/versiones-cras/agent-start/+server.ts diff --git a/src/lib/server/api-error.ts b/src/lib/server/api-error.ts index d182183..e8fc7b6 100644 --- a/src/lib/server/api-error.ts +++ b/src/lib/server/api-error.ts @@ -9,11 +9,20 @@ export function newTraceId(): string { return randomUUID(); } +/** + * Estados que el panel emite. Es una lista cerrada a propósito (Aduanasoft §5): si un error no + * encaja en ninguno, casi siempre es que está mal modelado, no que falte un código. + * + * Se exporta para que quien construya errores propios —como `AgentControlError`— tipe su estado + * con esto y el compilador lo valide donde se crea, en vez de descubrirlo al serializarlo. + */ +export type ApiErrorStatus = 400 | 401 | 403 | 404 | 409 | 422 | 500 | 502 | 503; + export function errorJson( // 502 para fallos de un servicio del que dependemos (p.ej. Gitea al sincronizar // versiones de CRAS): distinguirlo de 503 le dice al operador que el panel está bien y // el problema está aguas arriba. - code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 502 | 503, + code: ApiErrorStatus, message: string, traceId: string ) { diff --git a/src/lib/server/cras-agent-control.ts b/src/lib/server/cras-agent-control.ts new file mode 100644 index 0000000..578f6b1 --- /dev/null +++ b/src/lib/server/cras-agent-control.ts @@ -0,0 +1,231 @@ +/** + * Arrancar el agente de un servidor de restauración, desde el panel. + * + * Existe porque el diagnóstico sin acción no sirve de nada. La pantalla de Verificar sabía + * detectar "instalado pero detenido" y respondía con un comando de PowerShell y un botón de + * *Copiar*, dejando al operador la tarea de entrar por RDP o SSH al servidor y pegarlo. El panel + * ya tiene la sesión SSH, las credenciales y la elevación resueltas: pedirle eso al operador era + * gratuito para nosotros y caro para él. + * + * Solo arranca. No instala, no actualiza, no reescribe configuración y no detiene nada: si algo + * sale mal, el peor caso es un servidor que sigue exactamente como estaba. + */ +import SftpClient from 'ssh2-sftp-client'; + +import { getRestoreTargetSsh } from './controldesk-pg'; +import { + execRemote, + probeLinuxElevation, + probeWindowsElevation, + psEncoded, + shQuote, + type LinuxPrivilege +} from './cras-install'; +import { probeRemoteSystem } from './cras-verify'; +import { listCrasTargetInventory } from './cras-releases'; +import { effectiveInstallPath, DEFAULT_INSTALL_PATHS } from '$lib/cras-version'; +import type { ApiErrorStatus } from './api-error'; +import { logger } from './logger'; + +const CONNECT_TIMEOUT_MS = 20_000; +/** Margen para que el agente aparezca en la tabla de procesos tras pedir el arranque. */ +const ALIVE_TIMEOUT_MS = 45_000; +const POLL_INTERVAL_MS = 2_000; + +export class AgentControlError extends Error { + constructor( + public status: ApiErrorStatus, + message: string + ) { + super(message); + this.name = 'AgentControlError'; + } +} + +export interface StartAgentOutcome { + ok: boolean; + /** Qué se hizo y con qué resultado, en una línea, para mostrar tal cual en la UI. */ + detail: string; +} + +/** + * Sondea hasta que el agente aparezca vivo, o hasta agotar el margen. + * + * Se espera de verdad en lugar de responder en cuanto el comando de arranque retorna: tanto + * `Start-ScheduledTask` como `systemctl start` vuelven enseguida, y el binario es un onefile de + * ~270 MB que tarda en desempacarse. Contestar "arrancado" ahí sería la misma mentira que + * cometía la verificación de la instalación. + */ +async function waitAlive(check: () => Promise): Promise { + const deadline = Date.now() + ALIVE_TIMEOUT_MS; + for (;;) { + if (await check()) return true; + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } +} + +async function startOnWindows(sftp: SftpClient): Promise { + const privileged = await probeWindowsElevation(sftp); + if (privileged.elevation !== 'admin') { + throw new AgentControlError( + 409, + `No se puede arrancar la tarea programada: ${privileged.detail}` + ); + } + + const start = await execRemote( + sftp, + psEncoded( + "$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + + 'if (-not $t) { Write-Output "sin-tarea"; exit 0 }; ' + + "Start-ScheduledTask -TaskName 'CloudRestoreAS'; Write-Output 'arrancada'" + ) + ); + if (start.stdout.trim() === 'sin-tarea') { + throw new AgentControlError( + 409, + 'En el servidor no hay una tarea programada CloudRestoreAS que arrancar. Reinstala ' + + 'desde el panel eligiendo el arranque de servicio.' + ); + } + if (start.code !== 0) { + throw new AgentControlError( + 502, + `Start-ScheduledTask falló: ${start.stderr || start.stdout || 'sin salida'}` + ); + } + + const alive = await waitAlive(async () => { + const proc = await execRemote( + sftp, + psEncoded( + "if (Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue) " + + '{"si"} else {"no"}' + ) + ); + return proc.stdout.trim() === 'si'; + }); + + return alive + ? { ok: true, detail: 'Tarea CloudRestoreAS arrancada y proceso en ejecución.' } + : { + ok: false, + detail: + 'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' + + 'config\\logs en el servidor: el agente está fallando al iniciar.' + }; +} + +/** + * Ruta donde vive el agente, para poder reconocer su proceso. + * + * El ancla `^` del patrón no es opcional: sin ella, el `sh -c` que corre el propio `pgrep` lleva + * la ruta en su línea de comandos y haría match consigo mismo, reportando vivo un agente que + * nunca arrancó. Es el mismo motivo por el que install.sh ancla su patrón. + */ +async function resolveLinuxPrefix(restoreTargetId: number): Promise { + const inventory = await listCrasTargetInventory(); + const row = inventory.find((t) => t.restore_target_id === restoreTargetId); + return effectiveInstallPath(row?.reported_install_path ?? null, 'linux') ?? DEFAULT_INSTALL_PATHS.linux; +} + +async function startOnLinux( + sftp: SftpClient, + privileged: LinuxPrivilege, + prefix: string +): Promise { + const pgrep = `pgrep -f ${shQuote(`^${prefix}/CloudRestoreAS`)} >/dev/null && echo si || echo no`; + const isAlive = async () => (await execRemote(sftp, pgrep)).stdout.trim() === 'si'; + + // Unit de sistema primero: es la instalación recomendada. Si no hay elevación, `prefix` va + // vacío y systemctl fallará solo, sin efectos: ahí se pasa al unit de usuario. + let how = ''; + const system = await execRemote( + sftp, + `${privileged.prefix}systemctl start cloudrestoreas`, + undefined, + privileged.stdin + ); + if (system.code === 0) { + how = `systemctl start cloudrestoreas (elevación: ${privileged.label || 'ninguna'})`; + } else { + // Instalación sin privilegios: el unit vive en el bus del propio usuario. XDG_RUNTIME_DIR + // va explícito porque un `exec` de SSH no es una sesión de login y no siempre lo trae. + const user = await execRemote( + sftp, + 'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user start cloudrestoreas' + ); + if (user.code !== 0) { + throw new AgentControlError( + 502, + 'No se pudo arrancar el servicio ni como unit de sistema ni como unit de usuario. ' + + `Sistema: ${system.stderr || system.stdout || 'sin salida'}. ` + + `Usuario: ${user.stderr || user.stdout || 'sin salida'}.` + ); + } + how = 'systemctl --user start cloudrestoreas'; + } + + const alive = await waitAlive(isAlive); + return alive + ? { ok: true, detail: `${how}: el agente está en ejecución.` } + : { + ok: false, + detail: + `${how} no devolvió error, pero el proceso no apareció. Revisa ` + + '`journalctl -u cloudrestoreas -n 50` en el servidor.' + }; +} + +/** + * Arranca el agente en el destino indicado. Lanza `AgentControlError` con el estado HTTP que + * corresponde cuando el servidor no está en condiciones de que se le pida esto. + */ +export async function startCrasAgent(restoreTargetId: number): Promise { + const target = await getRestoreTargetSsh(restoreTargetId); + if (!target) { + throw new AgentControlError( + 409, + 'El servidor no tiene credenciales SSH completas (host, usuario y contraseña).' + ); + } + + const sftp = new SftpClient(`cras-start-${restoreTargetId}`); + try { + await sftp.connect({ + host: target.ssh_host, + port: target.ssh_port, + username: target.ssh_username, + password: target.ssh_password, + readyTimeout: CONNECT_TIMEOUT_MS + }); + + const system = await probeRemoteSystem(sftp); + if (system.verdict === 'windows') { + return await startOnWindows(sftp); + } + if (system.verdict === 'linux') { + const privileged = await probeLinuxElevation(sftp, target.ssh_password); + const prefix = await resolveLinuxPrefix(restoreTargetId); + return await startOnLinux(sftp, privileged, prefix); + } + throw new AgentControlError( + 409, + `No se pudo determinar el sistema del destino (${system.verdict}). Evidencia: ` + + `${system.evidence}` + ); + } finally { + try { + await sftp.end(); + } catch (err) { + logger.warn({ + message: 'No se pudo cerrar la sesión SFTP de arranque del agente', + context: { + target: target.name, + error: err instanceof Error ? err.message : String(err) + } + }); + } + } +} diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index 8cc9b07..f021d65 100644 --- a/src/lib/server/cras-install.test.ts +++ b/src/lib/server/cras-install.test.ts @@ -1,36 +1,69 @@ /** * Instalador remoto de CloudRestoreAS: utilidades de ejecución y quoting. * - * La prueba más importante de este archivo es la de fuga de secretos: el token del panel - * NUNCA debe aparecer en un comando remoto, porque `ps` y el historial del servidor destino - * son legibles por otros usuarios. Es exactamente el defecto del instalador de AServers, que - * hace `echo '{password}' | sudo -S ...`. + * La prueba más importante de este archivo es la de fuga de secretos: ni el token del panel ni + * la contraseña SSH deben aparecer NUNCA en un comando remoto, porque `ps` y el historial del + * servidor destino son legibles por otros usuarios. Es exactamente el defecto del instalador de + * AServers, que hace `echo '{password}' | sudo -S ...`. + * + * Nótese qué prohíbe ese invariante y qué no: prohíbe el secreto en la LÍNEA DE COMANDOS, no + * elevar con contraseña. `sudo -S` alimentado por el stdin del canal cumple la regla, y es lo + * que permite instalar en /opt sin ir a tocar el sudoers del servidor. */ import { describe, expect, it, vi } from 'vitest'; + +// La verificación del despliegue asienta cada paso en cras_install_runs, que va a PostgreSQL. +// Aquí interesa la DECISIÓN (¿esto cuenta como desplegado?), no la bitácora, así que se +// sustituye el módulo entero en vez de levantar una base. +vi.mock('./cras-releases', () => ({ + appendInstallStep: vi.fn(async () => {}), + finishInstallRun: vi.fn(async () => {}), + getCrasReleaseById: vi.fn(async () => null), + listCrasTargetInventory: vi.fn(async () => []), + startInstallRun: vi.fn(async () => 1), + InstallRunConflictError: class extends Error {} +})); + import { execRemote, probeInPlaceUpdate, probeLinuxElevation, + probeWindowsElevation, psEncoded, shQuote, + verifyWindowsDeployment, InstallError } from './cras-install'; /** Cliente SFTP falso que expone un `client.exec` controlable, como el real. */ function fakeSftp(handler: (command: string) => { code?: number; stdout?: string; stderr?: string }) { const commands: string[] = []; + /** Lo que se escribió en el stdin de cada comando, en el mismo orden que `commands`. */ + const stdins: (string | null)[] = []; const sftp = { client: { exec(command: string, callback: (err: Error | null, stream?: unknown) => void) { commands.push(command); + const index = stdins.push(null) - 1; const result = handler(command); const listeners: Record void)[]> = {}; const stderrListeners: ((...args: unknown[]) => void)[] = []; + let ended = false; const stream = { on(event: string, fn: (...args: unknown[]) => void) { (listeners[event] ??= []).push(fn); return stream; }, + write(chunk: string) { + stdins[index] = (stdins[index] ?? '') + chunk; + return true; + }, + end() { + ended = true; + }, + get stdinClosed() { + return ended; + }, stderr: { on(_event: string, fn: (...args: unknown[]) => void) { stderrListeners.push(fn); @@ -53,7 +86,7 @@ function fakeSftp(handler: (command: string) => { code?: number; stdout?: string } } }; - return { sftp, commands }; + return { sftp, commands, stdins }; } describe('shQuote', () => { @@ -277,21 +310,272 @@ describe('probeLinuxElevation', () => { const r = await probeLinuxElevation(sftp as never); expect(r.elevation).toBe('ninguna'); expect(r.prefix).toBe(''); - expect(r.detail).toMatch(/contraseña, que el panel nunca envía/); }); - it('NUNCA construye un prefijo que le pida la contraseña a sudo', async () => { - // La política del módulo: ni por argv ni por stdin. El modo sin privilegios existe - // precisamente para no tener que romperla. - for (const stderr of ['sudo: a password is required', 'sudo: sorry, you must have a tty']) { + it('sudo con la contraseña del panel: eleva cuando `sudo -n` no puede', async () => { + // El caso que dejaba tirado al operador: la cuenta SÍ está en el sudoers, solo que con + // contraseña. El panel ya la tiene —es con la que abrió la sesión— y ahora la usa. + const { sftp } = sftpFor({ + 'id -u': { code: 0, stdout: '1000' }, + 'sudo -n true': { code: 1, stderr: 'sudo: a password is required' }, + "sudo -S -k -p '' true": { code: 0 } + }); + const r = await probeLinuxElevation(sftp as never, 'secreto-del-panel'); + expect(r.elevation).toBe('sudo-con-password'); + expect(r.prefix).toContain('-S'); + // El prefijo y su stdin son inseparables: sin esto sudo se queda esperando. + expect(r.stdin).toBe('secreto-del-panel\n'); + }); + + it('la contraseña viaja por stdin y NUNCA por la línea de comandos', async () => { + // El invariante que hereda del defecto de AServers: `echo '{pw}' | sudo -S` deja la + // contraseña en el argv del `sh -c`, legible con `ps` por cualquier usuario del destino. + // Lo que se prohíbe es eso, no elevar con contraseña. + const PASSWORD = 'clave-que-no-debe-filtrarse'; + const { sftp, commands, stdins } = sftpFor({ + 'id -u': { code: 0, stdout: '1000' }, + 'sudo -n true': { code: 1, stderr: 'sudo: a password is required' }, + "sudo -S -k -p '' true": { code: 0 } + }); + const r = await probeLinuxElevation(sftp as never, PASSWORD); + + expect(commands.some((c) => c.includes(PASSWORD))).toBe(false); + expect(commands.some((c) => c.includes('echo'))).toBe(false); + expect(r.prefix).not.toContain(PASSWORD); + // Y sí llegó por el único canal admitido. + expect(stdins.some((s) => s?.includes(PASSWORD))).toBe(true); + }); + + it('requiretty NO gasta un intento de contraseña: sudo rechaza antes de mirar la política', async () => { + // Mandarle la contraseña ahí no arreglaría nada y sumaría un fallo al log de auth del + // destino, que es justo lo que dispara las alertas de fuerza bruta. + const { sftp, commands } = sftpFor({ + 'id -u': { code: 0, stdout: '1000' }, + 'sudo -n true': { code: 1, stderr: 'sudo: sorry, you must have a tty to run sudo' } + }); + const r = await probeLinuxElevation(sftp as never, 'secreto-del-panel'); + expect(r.elevation).toBe('requiretty'); + expect(commands.some((c) => c.includes('-S'))).toBe(false); + }); + + it('contraseña rechazada: lo dice, en vez de culpar a la falta de sudo', async () => { + // Los dos remedios son opuestos: meter al usuario en el sudoers, o corregir la + // contraseña registrada en el panel. Un mensaje genérico manda a hacer lo que no es. + const { sftp } = sftpFor({ + 'id -u': { code: 0, stdout: '1000' }, + 'sudo -n true': { code: 1, stderr: 'sudo: a password is required' }, + "sudo -S -k -p '' true": { code: 1, stderr: 'sudo: 1 incorrect password attempt' }, + 'sudo -n -l': { code: 1, stdout: '' } + }); + const r = await probeLinuxElevation(sftp as never, 'clave-vieja'); + expect(r.elevation).toBe('ninguna'); + expect(r.prefix).toBe(''); + expect(r.detail).toMatch(/contraseña SSH guardada en el panel/); + }); +}); + +/** + * Sonda de elevación en Windows. + * + * El estado que se añadió —y el motivo de estas pruebas— es "administrador con token filtrado": + * una cuenta que SÍ está en Administradores pero a la que OpenSSH entregó el token filtrado por + * UAC. Se veía igual que "no eres administrador", y el remedio es el opuesto: ahí la cuenta ya + * es la correcta y lo que falta es una política del servidor. + */ +describe('probeWindowsElevation', () => { + /** El comando va base64/UTF-16LE dentro de -EncodedCommand: hay que decodificarlo. */ + function fakePsSftp(reply: string) { + return fakeSftp((command) => { + const encoded = command.split('-EncodedCommand ')[1] ?? ''; + const script = Buffer.from(encoded, 'base64').toString('utf16le'); + // Se comprueba que la sonda mire el SID del grupo, que es lo único que distingue el + // token filtrado de una cuenta sin privilegios. + if (!script.includes('S-1-5-32-544')) return { code: 1, stdout: '' }; + return { code: 0, stdout: reply }; + }); + } + + it('administrador con token completo', async () => { + const { sftp } = fakePsSftp('admin'); + const r = await probeWindowsElevation(sftp as never); + expect(r.elevation).toBe('admin'); + expect(r.detail).toBe(''); + }); + + it('token filtrado por UAC: NO manda a cambiar de cuenta', async () => { + const { sftp } = fakePsSftp('token-filtrado'); + const r = await probeWindowsElevation(sftp as never); + expect(r.elevation).toBe('token-filtrado'); + // El remedio correcto es la política del servidor, no otra cuenta. + expect(r.detail).toMatch(/LocalAccountTokenFilterPolicy/); + expect(r.detail).toMatch(/No hay que cambiar de cuenta/); + }); + + it('cuenta sin privilegios: ahí sí hace falta otra cuenta', async () => { + const { sftp } = fakePsSftp('limitado'); + const r = await probeWindowsElevation(sftp as never); + expect(r.elevation).toBe('limitado'); + expect(r.detail).toMatch(/cuenta administradora/); + }); +}); + +/** + * Verificación del despliegue en Windows. + * + * El defecto que cubre: se daba por buena cualquier tarea con un `State` no vacío. `Ready` es una + * tarea REGISTRADA QUE NO ESTÁ CORRIENDO —exactamente lo que se ve cuando el agente arrancó y + * murió a los pocos segundos—, así que una actualización que dejaba el servidor sin restaurador + * se asentaba como completada y nadie se enteraba hasta que alguien pulsaba Verificar. + */ +describe('verifyWindowsDeployment', () => { + const RELEASE = { version: '1.1.2' } as never; + + /** Responde a cada script de PowerShell según lo que pregunte. */ + function fakeWindows(opts: { stamp: string; taskState: string; running: boolean }) { + return fakeSftp((command) => { + const encoded = command.split('-EncodedCommand ')[1] ?? ''; + const script = Buffer.from(encoded, 'base64').toString('utf16le'); + if (script.includes('config\\.version')) return { code: 0, stdout: opts.stamp }; + if (script.includes('Get-Process')) { + return { code: 0, stdout: opts.running ? 'corriendo:1' : 'detenido' }; + } + if (script.includes('Get-ScheduledTask')) return { code: 0, stdout: opts.taskState }; + return { code: 1, stdout: '' }; + }); + } + + it('tarea Ready con el proceso muerto NO cuenta como desplegado', async () => { + const { sftp } = fakeWindows({ stamp: '1.1.2', taskState: 'Ready', running: false }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') + ).rejects.toThrow(/el proceso no está corriendo/); + }); + + it('tarea corriendo y proceso vivo sí cuenta', async () => { + const { sftp } = fakeWindows({ stamp: '1.1.2', taskState: 'Running', running: true }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') + ).resolves.toBeUndefined(); + }); + + it('el proceso se comprueba también fuera del modo servicio', async () => { + // Antes se salía antes de mirar nada cuando el arranque no era 'service', así que + // actualizar con 'none' dejaba el agente muerto y el run quedaba en verde. + const { sftp, commands } = fakeWindows({ stamp: '1.1.2', taskState: '', running: false }); + await verifyWindowsDeployment(sftp as never, 1, RELEASE, 'none', 'C:\\CRAS'); + const scripts = commands.map((c) => + Buffer.from(c.split('-EncodedCommand ')[1] ?? '', 'base64').toString('utf16le') + ); + expect(scripts.some((s) => s.includes('Get-Process'))).toBe(true); + }); + + it('un sello con otra versión es un fallo, no un aviso', async () => { + const { sftp } = fakeWindows({ stamp: '1.1.1', taskState: 'Running', running: true }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') + ).rejects.toThrow(/reporta la versión 1\.1\.1/); + }); +}); + +/** + * Precondiciones de la actualización en sitio sin privilegios. + * + * Cada rechazo tiene un remedio distinto, y por eso no basta un booleano. Lo que se prueba aquí + * son los NO: el sí es trivial y el daño está en aceptar un servidor donde no se puede. + */ +describe('probeInPlaceUpdate', () => { + const PREFIX = '/opt/cloudrestoreas'; + + function sftpFor(responses: Record) { + return fakeSftp((command) => { + for (const [needle, result] of Object.entries(responses)) { + if (command.includes(needle)) return result; + } + return { code: 0, stdout: '' }; + }); + } + + /** Destino ideal: ruta del usuario, unit suyo con Restart=always, sin jobs. */ + const OK = { + 'test -w': { code: 0, stdout: 'si' }, + 'systemctl show': { code: 0, stdout: 'User=srvmid_db\nRestart=always' }, + '/Temp': { code: 0, stdout: '' } + }; + + it('acepta cuando se cumplen las tres condiciones', async () => { + const { sftp } = sftpFor(OK); + expect(await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db')).toMatchObject({ ok: true }); + }); + + it('rechaza si el directorio no es escribible, y dice de quién es', async () => { + // Sin escritura en el DIRECTORIO no se puede reemplazar el binario, y es el único + // permiso que hace falta: `install` desvincula antes de crear. + const { sftp } = sftpFor({ + ...OK, + 'test -w': { code: 1, stdout: 'no' }, + 'stat -c': { code: 0, stdout: 'root' } + }); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason).toContain('root'); + expect(r.reason).toContain(PREFIX); + }); + + it('rechaza si el servicio corre como otro usuario', async () => { + // Si el unit quedó con User=root —alguien instaló desde un `sudo -i`— la cuenta SSH no + // puede señalizar el proceso y toda la vía se cae. + const { sftp } = sftpFor({ + ...OK, + 'systemctl show': { code: 0, stdout: 'User=root\nRestart=always' } + }); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/corre como 'root'/); + }); + + it('trata el User vacío como root, que es lo que hace systemd', async () => { + const { sftp } = sftpFor({ + ...OK, + 'systemctl show': { code: 0, stdout: 'User=\nRestart=always' } + }); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/root/); + }); + + it('rechaza sin Restart=always: matarlo lo dejaría muerto', async () => { + for (const restart of ['on-failure', 'no', '']) { const { sftp } = sftpFor({ - 'id -u': { code: 0, stdout: '1000' }, - 'sudo -n true': { code: 1, stderr }, - 'sudo -n -l': { code: 1 } + ...OK, + 'systemctl show': { code: 0, stdout: `User=srvmid_db\nRestart=${restart}` } }); - const r = await probeLinuxElevation(sftp as never); - expect(r.prefix).not.toMatch(/-S/); - expect(r.prefix).not.toMatch(/echo/); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok, `Restart=${restart}`).toBe(false); + expect(r.reason).toMatch(/Restart=always/); + } + }); + + it('rechaza si hay una restauración en curso, antes de subir 270 MB', async () => { + // El mismo chequeo lo repite install.sh en el destino, pero hacerlo aquí evita la + // transferencia completa para nada. + const { sftp } = sftpFor({ ...OK, '/Temp': { code: 0, stdout: 'job-4212' } }); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/restauración en curso/); + }); + + it('el motivo del rechazo nunca queda vacío: cada caso tiene su remedio', async () => { + const casos = [ + { ...OK, 'test -w': { code: 1, stdout: 'no' } }, + { ...OK, 'systemctl show': { code: 0, stdout: 'User=otro\nRestart=always' } }, + { ...OK, 'systemctl show': { code: 0, stdout: 'User=srvmid_db\nRestart=no' } }, + { ...OK, '/Temp': { code: 0, stdout: 'job-1' } } + ]; + for (const caso of casos) { + const { sftp } = sftpFor(caso); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason.trim().length).toBeGreaterThan(20); } }); }); diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index f1933fb..e61f4d5 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -12,11 +12,20 @@ * subido por SFTP, no como argumento: `ps` y el historial del destino son legibles por * otros usuarios. AServers hace `echo '{password}' | sudo -S ...`, que expone el password * y además permite inyección de comandos. - * - **Nunca se le pasa el password a sudo**, ni por argv ni por stdin. Para los modos que - * instalan en /opt y registran un servicio de sistema se exige root o `sudo -n` (sudo sin - * password), y si no hay ninguno se aborta ANTES de subir 270 MB. El modo `user-service` - * evita el problema en vez de rodearlo: instala bajo el home con un unit de systemd de - * usuario, así que no necesita elevación alguna y le bastan las credenciales SSH. + * - **El password de sudo va por el stdin del canal `exec`, nunca por argv.** Es la distinción + * que importa, y la que el defecto de AServers no hace: lo que expone `echo '{pw}' | sudo -S` + * es que la contraseña acaba en la línea de comandos del `sh -c` que la ejecuta, visible con + * `ps` para cualquier usuario del destino. Escribirla en el stdin del canal no pasa por + * ningún argv, ningún historial ni ningún proceso intermedio, y al no concatenarse a un + * comando tampoco abre la puerta a inyección. Es la MISMA credencial con la que ya se + * autenticó la sesión SSH, así que no amplía la superficie: el residuo que queda es que vive + * en memoria del proceso del panel (ya vivía) y que sudo asienta el intento en el log de auth + * del destino. A cambio, instalar en /opt con servicio de sistema deja de exigir que alguien + * toque el sudoers del servidor. + * - **La elevación se agota en orden, y solo entonces se rinde:** root → `sudo -n` → + * `sudo -S` con la credencial del panel → actualización en sitio (que no eleva nada) → + * `user-service` bajo el home. Abortar antes de agotarlas devolvía al operador a la consola + * del servidor, que es justo lo que este módulo existe para evitar. * - **El sha256 se verifica en el destino** antes de extraer, no solo al cachear: así se * detecta una transferencia corrupta. * - **El progreso se persiste paso a paso** en cras_install_runs.steps para que la UI lo @@ -54,6 +63,13 @@ const EXEC_TIMEOUT_MS = 120_000; /** El bootstrap del binario corre con `timeout 20` del lado del destino; se da margen. */ const INSTALL_EXEC_TIMEOUT_MS = 300_000; +/** + * Prefijo de elevación cuando sudo pide contraseña. La contraseña NO va aquí: viaja por el stdin + * del canal (`-S`), y este prefijo es lo único que se concatena al comando, así que el argv del + * destino nunca la contiene. Ver `probeLinuxElevation` para el porqué de `-k` y `-p ''`. + */ +const SUDO_PASSWORD_PREFIX = "sudo -S -k -p '' "; + export class InstallError extends Error { constructor( public status: number, @@ -76,11 +92,19 @@ export interface ExecResult { * `ssh2-sftp-client` envuelve un `ssh2.Client` y lo expone en `.client`, pero sus tipos no * lo declaran; de ahí el cast. Se reutiliza esa conexión en lugar de abrir una segunda para * no autenticarse dos veces por instalación. + * + * `stdin` es el canal por el que viaja la contraseña de sudo (ver `probeLinuxElevation`). Es un + * parámetro aparte —y no algo que el llamador pueda concatenar a `command`— justamente para que + * un secreto no pueda acabar en la línea de comandos: el argv del destino es legible por + * cualquier usuario con `ps`. Nada de lo que se escriba aquí se registra en logs ni en los pasos + * del run. Se cierra el canal inmediatamente después de escribir: sudo consume su línea y el + * proceso real no hereda nada del resto. */ export function execRemote( sftp: SftpClient, command: string, - timeoutMs = EXEC_TIMEOUT_MS + timeoutMs = EXEC_TIMEOUT_MS, + stdin?: string ): Promise { const conn = (sftp as unknown as { client: SshClient }).client; if (!conn || typeof conn.exec !== 'function') { @@ -103,6 +127,12 @@ export function execRemote( reject(new InstallError(502, `No se pudo ejecutar en el destino: ${err.message}`)); return; } + // Solo se toca stdin cuando hay algo que mandar: cerrarlo siempre cambiaría el + // contrato de las decenas de comandos que hoy lo dejan abierto. + if (stdin !== undefined) { + stream.write(stdin); + stream.end(); + } let stdout = ''; let stderr = ''; let code = -1; @@ -500,15 +530,19 @@ async function installLinux( const systemEvidence = await assertSystemMatches(sftp, 'linux'); // El modo `user-service` instala en el home con un unit de usuario, así que no necesita - // ninguna elevación. Los modos de sistema sí: ahí la falta de privilegios se aborta aquí, - // antes de transferir 270 MB, porque fallar después es desperdicio y deja basura en /tmp. + // ninguna elevación. Los modos de sistema sí, y para ellos se agotan TODAS las vías — + // incluida la contraseña SSH que el panel ya tiene— antes de rendirse. Lo que no se hace es + // fallar después de transferir 270 MB: la decisión se toma aquí, con la sesión abierta. const autostartMode = request.autostart ?? 'service'; - const privileged = await probeLinuxElevation(sftp); - const canElevate = privileged.elevation === 'root' || privileged.elevation === 'sudo-sin-password'; + const privileged = await probeLinuxElevation(sftp, target.ssh_password); + const canElevate = + privileged.elevation === 'root' || + privileged.elevation === 'sudo-sin-password' || + privileged.elevation === 'sudo-con-password'; - // Sin elevación quedan dos vías antes de rendirse, y ambas evitan pedirle la contraseña a - // sudo. `user-service` instala en el home; la actualización en sitio deja el unit como está - // y solo reemplaza el binario, que es lo único que una actualización necesita de verdad. + // Sin ninguna forma de elevar quedan dos vías antes de rendirse. `user-service` instala en el + // home; la actualización en sitio deja el unit como está y solo reemplaza el binario, que es + // lo único que una actualización necesita de verdad. let inPlaceUpdate = false; if (!canElevate && autostartMode !== 'user-service') { if (request.mode !== 'update') { @@ -621,8 +655,21 @@ async function installLinux( `${privileged.prefix}env PREFIX=${shQuote(installPath)} ` + `./install.sh${installerFlags} --panel-env-file ${shQuote(remoteEnv)}`; - await appendInstallStep(runId, 'ejecutar-instalador', true, `install.sh${installerFlags}`); - const install = await execRemote(sftp, installCmd, INSTALL_EXEC_TIMEOUT_MS); + await appendInstallStep( + runId, + 'ejecutar-instalador', + true, + `install.sh${installerFlags} (elevación: ${privileged.label || 'ninguna'})` + ); + // `privileged.stdin` solo trae algo en la vía `sudo -S`, y es lo que sudo consume antes + // de ceder el canal. install.sh no lee stdin —sus bucles `read` leen de archivo y su + // bootstrap se protege con ` { +export async function probeLinuxElevation( + sftp: SftpClient, + sshPassword?: string +): Promise { const id = await execRemote(sftp, 'id -u'); if (id.code === 0 && id.stdout.trim() === '0') { return { prefix: '', label: 'root', elevation: 'root', detail: '' }; @@ -715,6 +783,32 @@ export async function probeLinuxElevation(sftp: SftpClient): Promise { + const probe = await execRemote( + sftp, + psEncoded( + '$id=[Security.Principal.WindowsIdentity]::GetCurrent();' + + '$p=[Security.Principal.WindowsPrincipal]$id;' + + "$sid=New-Object Security.Principal.SecurityIdentifier 'S-1-5-32-544';" + + '$inGroup=[bool]($id.Groups | Where-Object { $_ -eq $sid });' + + 'if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){"admin"}' + + 'elseif($inGroup){"token-filtrado"}else{"limitado"}' + ) + ); + + switch (probe.stdout.trim()) { + case 'admin': + return { elevation: 'admin', label: 'administrador', detail: '' }; + case 'token-filtrado': + return { + elevation: 'token-filtrado', + label: 'administrador con token filtrado por UAC', + detail: + 'la cuenta SÍ pertenece a Administradores, pero la sesión SSH recibió el token ' + + 'filtrado por UAC, así que no puede mandar sobre una tarea que corre como ' + + 'SYSTEM. No hay que cambiar de cuenta: hay que permitir la elevación remota en ' + + 'el servidor, poniendo LocalAccountTokenFilterPolicy=1 (DWORD) en ' + + 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System.' + }; + default: + return { + elevation: 'limitado', + label: 'sin privilegios', + detail: + 'la cuenta SSH no pertenece a Administradores del destino, y la tarea programada ' + + 'corre como SYSTEM. Hace falta una cuenta administradora.' + }; + } +} + async function installWindows( sftp: SftpClient, runId: number, @@ -954,26 +1113,35 @@ async function installWindows( // --- Precondiciones ------------------------------------------------------ const systemEvidence = await assertSystemMatches(sftp, 'windows'); - const admin = await execRemote( + const privileged = await probeWindowsElevation(sftp); + const autostart = request.autostart ?? 'service'; + + // La elevación hace falta para MANDAR sobre la tarea, que corre como SYSTEM: registrarla en + // una instalación nueva, o pararla y arrancarla en una actualización. Se comprueba si existe + // en lugar de deducirlo del modo, porque un servidor puede tener la tarea puesta aunque esta + // corrida pida otro arranque, y ahí igual hay que poder detenerla. + const taskProbe = await execRemote( sftp, psEncoded( - '$p=[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent();' + - 'if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){"admin"}else{"limitado"}' + "if (Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue) " + + '{"si"} else {"no"}' ) ); - const autostart = request.autostart ?? 'service'; - if (autostart === 'service' && admin.stdout.trim() !== 'admin') { + const taskExists = taskProbe.stdout.trim() === 'si'; + const needsElevation = autostart === 'service' || taskExists; + + if (needsElevation && privileged.elevation !== 'admin') { throw new InstallError( 409, - 'El usuario SSH no es Administrador en el destino. La tarea programada ONSTART corre ' + - 'como SYSTEM y requiere elevación; usa una cuenta administradora o instala sin servicio.' + `No se puede operar la tarea programada de ${target.name}: ${privileged.detail}` ); } await appendInstallStep( runId, 'precondiciones', true, - `${systemEvidence}, privilegios=${admin.stdout.trim()}` + `${systemEvidence}, privilegios=${privileged.label}` + + (taskExists ? ', tarea CloudRestoreAS ya registrada' : '') ); if (request.mode === 'update') { @@ -1067,7 +1235,18 @@ async function installWindows( ); } - const flag = autostart === 'none' ? '' : autostart === 'service' ? ' -Service' : ' -Desktop'; + // Actualizar va SIEMPRE por -UpdateInPlace, sea cual sea el arranque configurado: no + // vuelve a registrar la tarea (así no pisa ajustes que el operador haya hecho sobre ella), + // se salta el bootstrap —una segunda instancia purgaría el Temp\ de la que está viva— y + // trae respaldo con reversión automática si el binario nuevo no levanta. + const inPlaceUpdate = request.mode === 'update'; + const flag = inPlaceUpdate + ? ' -UpdateInPlace' + : autostart === 'none' + ? '' + : autostart === 'service' + ? ' -Service' + : ' -Desktop'; await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`); const install = await execRemote( sftp, @@ -1077,6 +1256,17 @@ async function installWindows( ), INSTALL_EXEC_TIMEOUT_MS ); + // 75 = EX_TEMPFAIL, igual que en install.sh: hay una restauración en curso. Es un + // conflicto de estado, no un fallo; con el 502 genérico el operador salía a buscar una + // avería que no existe. + if (install.code === 75) { + throw new InstallError( + 409, + `${target.name} está restaurando un respaldo ahora mismo, así que no se actualizó ` + + 'para no interrumpirlo —eso dejaría ese respaldo vetado y la base en ' + + 'SINGLE_USER—. Reintenta cuando termine.' + ); + } if (install.code !== 0) { throw new InstallError( 502, @@ -1084,7 +1274,9 @@ async function installWindows( ); } - await verifyWindowsDeployment(sftp, runId, release, autostart, installPath); + await verifyWindowsDeployment( + sftp, runId, release, autostart, installPath, inPlaceUpdate + ); } finally { await cleanupStaging( sftp, @@ -1097,25 +1289,45 @@ async function installWindows( } } -async function verifyWindowsDeployment( +export async function verifyWindowsDeployment( sftp: SftpClient, runId: number, release: CrasRelease, autostart: AutostartMode, - prefix: string + prefix: string, + inPlaceUpdate = false ): Promise { - const stamp = await execRemote( - sftp, - psEncoded( - `if (Test-Path '${prefix}\\config\\.version') ` + - `{ Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw }` - ) - ); - const deployed = stamp.stdout.trim(); + // El sello config\.version lo escribe el bootstrap del binario. + // + // Al actualizar en sitio NO se corre el bootstrap, así que el sello lo escribe el propio + // agente al reiniciarse y durante unos segundos sigue teniendo la versión ANTERIOR. Leerlo + // una sola vez convertía una actualización correcta en un fallo "reporta 1.1.1, se esperaba + // 1.1.2" con el binario ya bien instalado y corriendo. Y en Windows pica más que en Linux: + // desempacar un onefile de ~270 MB con Defender escaneando cada archivo se pasa de largo del + // margen. Se sondea hasta que coincida, o hasta agotar los intentos. + let deployed = ''; + const stampAttempts = inPlaceUpdate ? 15 : 3; + for (let attempt = 0; attempt < stampAttempts; attempt += 1) { + if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2000)); + const stamp = await execRemote( + sftp, + psEncoded( + `if (Test-Path '${prefix}\\config\\.version') ` + + `{ Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw }` + ) + ); + deployed = stamp.stdout.trim(); + // Vacío = todavía no lo escribió, y eso ya se toleraba antes. + if (!deployed || deployed === release.version) break; + } if (deployed && deployed !== release.version) { throw new InstallError( 502, - `El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}` + `El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}. ` + + (inPlaceUpdate + ? 'El sello no cambió tras reiniciar el agente, así que el proceso puede seguir ' + + 'ejecutando el binario anterior.' + : '') ); } await appendInstallStep( @@ -1125,7 +1337,31 @@ async function verifyWindowsDeployment( deployed ? `config\\.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)' ); - if (autostart !== 'service') return; + // El proceso vivo es la evidencia que manda, y se comprueba en TODOS los modos. Antes se + // salía aquí cuando el arranque no era 'service', así que una actualización que dejaba el + // servidor sin agente se asentaba como completada y nadie se enteraba hasta que alguien + // pulsaba Verificar. install.ps1 ya garantiza el rearranque; esto lo confirma desde fuera. + const proc = await execRemote( + sftp, + psEncoded( + "$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + + 'if ($p) { "corriendo:" + $p.Count } else { "detenido" }' + ) + ); + const running = proc.stdout.trim().startsWith('corriendo'); + + if (autostart !== 'service') { + // Sin tarea que consultar, el proceso es lo único que hay. En 'desktop' el agente arranca + // al iniciar sesión, así que no tenerlo vivo ahora no es un fallo; en el resto sí importa + // y se asienta tal cual para que la bitácora no mienta. + await appendInstallStep( + runId, + 'verificar-proceso', + running || autostart === 'desktop', + running ? 'CloudRestoreAS en ejecución' : 'CloudRestoreAS no está en ejecución' + ); + return; + } const task = await execRemote( sftp, @@ -1140,7 +1376,23 @@ async function verifyWindowsDeployment( 'La tarea programada CloudRestoreAS no quedó registrada en el destino.' ); } - await appendInstallStep(runId, 'verificar-servicio', true, `tarea CloudRestoreAS: ${state}`); + // `Ready` es una tarea registrada que NO está corriendo: es exactamente lo que se ve cuando + // el agente arrancó y murió a los pocos segundos. Aceptar cualquier estado no vacío hacía + // pasar por buena una instalación que dejaba el servidor sin restaurador. + if (!running) { + throw new InstallError( + 502, + `La tarea CloudRestoreAS quedó registrada (estado: ${state}) pero el proceso no está ` + + `corriendo, así que el agente no levantó. Revisa ${prefix}\\config\\logs en el ` + + 'servidor.' + ); + } + await appendInstallStep( + runId, + 'verificar-servicio', + true, + `tarea CloudRestoreAS: ${state}, proceso en ejecución` + ); } // ============================================================================ diff --git a/src/lib/server/cras-verify.ts b/src/lib/server/cras-verify.ts index 1671dac..d965249 100644 --- a/src/lib/server/cras-verify.ts +++ b/src/lib/server/cras-verify.ts @@ -73,13 +73,18 @@ export interface VerifyResult { export interface Remediation { /** Descripción de para qué sirve. */ title: string; - /** Comando exacto a correr EN el servidor destino, para copiar y pegar. */ + /** Comando equivalente EN el servidor destino. Referencia y registro, no la vía principal. */ command: string | null; - /** Dónde correrlo. */ + /** Dónde correrlo, cuando hay que correrlo a mano. */ where: string; /** - * Si el agente podría aplicarlo por su cuenta (corre local, no necesita SSH). Hoy es - * informativo: la ejecución remota desde el panel no está implementada. + * Si el PANEL puede aplicarlo por sí mismo, por la sesión SSH que ya tiene. Cuando es `true` + * la UI ofrece un botón que llama a POST /versiones-cras/agent-start, y el `command` queda + * como referencia para quien quiera hacerlo a mano o auditar qué se ejecutó. + * + * Dejarlo en `false` significa que el remedio está fuera del alcance del panel —capturar + * credenciales, corregir el sudoers, una política de UAC del servidor—, no que no se haya + * implementado. */ agent_could_apply: boolean; notes: string[]; @@ -475,7 +480,11 @@ export async function verifyCrasTarget(restoreTargetId: number): Promise { const checks: VerifyCheck[] = []; const prefix = effectiveInstallPath(reportedInstallPath, 'linux') ?? DEFAULT_INSTALL_PATHS.linux; - // La sonda es la MISMA que usa el instalador. Antes había aquí una copia paralela, y ya - // diferían en la etiqueta: dos pantallas contradiciéndose sobre el mismo hecho. - const elevation = await probeLinuxElevation(sftp); - const privileged = elevation.elevation === 'root' || elevation.elevation === 'sudo-sin-password'; + // La sonda es la MISMA que usa el instalador, y recibe la MISMA contraseña. Antes había aquí + // una copia paralela, y ya diferían en la etiqueta: dos pantallas contradiciéndose sobre el + // mismo hecho. Omitir aquí la contraseña reabriría esa grieta —Verificar diría "sin + // privilegios" de un servidor donde Instalar sí puede elevar—, que es peor que el original + // porque el desacuerdo sería sobre si la instalación va a funcionar. + const elevation = await probeLinuxElevation(sftp, sshPassword); + const privileged = + elevation.elevation === 'root' || + elevation.elevation === 'sudo-sin-password' || + elevation.elevation === 'sudo-con-password'; checks.push( check( 'privilegios', @@ -704,8 +720,8 @@ async function inspectLinux( remediation: { title: 'Arrancar el servicio del agente', command: 'sudo systemctl start cloudrestoreas && sudo systemctl status cloudrestoreas', - where: 'En una terminal del servidor, con privilegios de root.', - agent_could_apply: false, + where: 'El panel puede hacerlo por la sesión SSH que ya tiene.', + agent_could_apply: true, notes: ['Para ver la causa: sudo journalctl -u cloudrestoreas -n 50'] } }; @@ -844,8 +860,12 @@ async function inspectWindows( remediation: { title: 'Arrancar el agente en el servidor', command: 'Start-ScheduledTask -TaskName CloudRestoreAS', - where: 'En PowerShell como Administrador, en el servidor.', - agent_could_apply: false, + // Sin tarea registrada no hay nada que arrancar: el remedio es reinstalar, y + // ofrecer un botón que no puede funcionar es peor que no ofrecerlo. + where: scheduled + ? 'El panel puede hacerlo por la sesión SSH que ya tiene.' + : 'Panel → Versiones CRAS → Instalar, con arranque de servicio.', + agent_could_apply: scheduled, notes: [ `Logs del agente: ${prefix}\\config\\logs`, scheduled ? '' : 'La tarea no está registrada: reinstala desde el panel con arranque de servicio.' diff --git a/src/routes/versiones-cras/+page.svelte b/src/routes/versiones-cras/+page.svelte index d22b4c5..a31d7d3 100644 --- a/src/routes/versiones-cras/+page.svelte +++ b/src/routes/versiones-cras/+page.svelte @@ -254,6 +254,9 @@ verifyResult = null; verifyError = null; copiedKey = null; + // Un resultado de arranque pertenece a la verificación que lo produjo. `startAgent` lo + // vuelve a poner tras re-verificar; cualquier otra verificación debe empezar limpia. + startAgentOutcome = null; try { const res = await fetch(`/versiones-cras/verify?targetId=${targetId}`); const body = await res.json(); @@ -269,6 +272,46 @@ } } + /** Arranque del agente pedido desde el panel: en curso, y cómo terminó. */ + let startingAgent = $state(false); + let startAgentOutcome = $state<{ ok: boolean; detail: string } | null>(null); + + /** + * Aplica el remedio "arrancar el agente" sin salir del panel. + * + * Al terminar se vuelve a verificar en lugar de creerle a la respuesta: lo que interesa es el + * estado del servidor, no que el comando no diera error. `startAgentOutcome` se guarda antes + * de re-verificar porque `verifyTarget` limpia el resultado anterior. + */ + async function startAgent(targetId: number) { + startingAgent = true; + startAgentOutcome = null; + try { + const res = await fetch('/versiones-cras/agent-start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetId }) + }); + const body = await res.json(); + if (!res.ok) { + startAgentOutcome = { + ok: false, + detail: body?.error?.message ?? `El panel respondió ${res.status}` + }; + } else { + startAgentOutcome = { ok: Boolean(body.ok), detail: String(body.detail ?? '') }; + } + } catch (e) { + startAgentOutcome = { ok: false, detail: e instanceof Error ? e.message : String(e) }; + } finally { + startingAgent = false; + } + + const outcome = startAgentOutcome; + await verifyTarget(targetId); + startAgentOutcome = outcome; + } + async function copyValue(key: string, value: string) { try { await navigator.clipboard.writeText(value); @@ -1044,17 +1087,35 @@ {/if} {#if r.agent_could_apply} -

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

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

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

+ {startAgentOutcome.detail} +

+ {/if} +

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

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