/** * 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, probeWindowsAgentProcess, probeWindowsElevation, psEncoded, shQuote, type LinuxPrivilege } from './cras-install'; import { probeRemoteSystem } from './cras-verify'; import { listCrasTargetInventory } from './cras-releases'; import { effectiveInstallPath, DEFAULT_INSTALL_PATHS, type CrasPlatform } 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, prefix: string): 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'}` ); } // Se espera con la MISMA sonda que usa el instalador, que mira la ruta del ejecutable y no solo // el nombre. Con la comprobación por nombre, un agente viejo corriendo desde otra carpeta hacía // pasar por bueno un arranque que no ocurrió. let ultima = await probeWindowsAgentProcess(sftp, prefix); const alive = await waitAlive(async () => { ultima = await probeWindowsAgentProcess(sftp, prefix); // `paths` vacío con el proceso vivo es "no pude ver desde dónde corre" (un proceso de SYSTEM // no expone su ruta sin elevación), y eso se acepta: no se afirma que sea de otra carpeta. return ultima.fromPrefix || (ultima.running && ultima.paths.length === 0); }); if (alive) { return { ok: true, detail: 'Tarea CloudRestoreAS arrancada y proceso en ejecución' + (ultima.paths.length ? ` desde ${ultima.paths.join(', ')}.` : '.') }; } return { ok: false, detail: ultima.running ? `Hay un agente corriendo desde ${ultima.paths.join(', ')}, no desde ${prefix}: el ` + 'arranque automático apunta a otra instalación.' : 'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' + `${prefix}\\config\\logs en el servidor: el agente está fallando al iniciar.` }; } /** * Ruta donde vive el agente, para poder reconocer su proceso. * * Hace falta en las dos plataformas y por el mismo motivo: sin ella solo se puede preguntar por el * NOMBRE del proceso, que no distingue el agente que nos interesa de otro corriendo desde otra * carpeta. En Linux, además, el ancla `^` del patrón de `pgrep` 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ó. */ async function resolvePrefix(restoreTargetId: number, platform: CrasPlatform): Promise { const inventory = await listCrasTargetInventory(); const row = inventory.find((t) => t.restore_target_id === restoreTargetId); return ( effectiveInstallPath(row?.reported_install_path ?? null, platform) ?? DEFAULT_INSTALL_PATHS[platform] ); } 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, await resolvePrefix(restoreTargetId, 'windows')); } if (system.verdict === 'linux') { const privileged = await probeLinuxElevation(sftp, target.ssh_password); const prefix = await resolvePrefix(restoreTargetId, 'linux'); 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) } }); } } }