diff --git a/src/lib/server/cras-agent-control.ts b/src/lib/server/cras-agent-control.ts new file mode 100644 index 0000000..578f6b1 --- /dev/null +++ b/src/lib/server/cras-agent-control.ts @@ -0,0 +1,231 @@ +/** + * Arrancar el agente de un servidor de restauración, desde el panel. + * + * Existe porque el diagnóstico sin acción no sirve de nada. La pantalla de Verificar sabía + * detectar "instalado pero detenido" y respondía con un comando de PowerShell y un botón de + * *Copiar*, dejando al operador la tarea de entrar por RDP o SSH al servidor y pegarlo. El panel + * ya tiene la sesión SSH, las credenciales y la elevación resueltas: pedirle eso al operador era + * gratuito para nosotros y caro para él. + * + * Solo arranca. No instala, no actualiza, no reescribe configuración y no detiene nada: si algo + * sale mal, el peor caso es un servidor que sigue exactamente como estaba. + */ +import SftpClient from 'ssh2-sftp-client'; + +import { getRestoreTargetSsh } from './controldesk-pg'; +import { + execRemote, + probeLinuxElevation, + probeWindowsElevation, + psEncoded, + shQuote, + type LinuxPrivilege +} from './cras-install'; +import { probeRemoteSystem } from './cras-verify'; +import { listCrasTargetInventory } from './cras-releases'; +import { effectiveInstallPath, DEFAULT_INSTALL_PATHS } from '$lib/cras-version'; +import type { ApiErrorStatus } from './api-error'; +import { logger } from './logger'; + +const CONNECT_TIMEOUT_MS = 20_000; +/** Margen para que el agente aparezca en la tabla de procesos tras pedir el arranque. */ +const ALIVE_TIMEOUT_MS = 45_000; +const POLL_INTERVAL_MS = 2_000; + +export class AgentControlError extends Error { + constructor( + public status: ApiErrorStatus, + message: string + ) { + super(message); + this.name = 'AgentControlError'; + } +} + +export interface StartAgentOutcome { + ok: boolean; + /** Qué se hizo y con qué resultado, en una línea, para mostrar tal cual en la UI. */ + detail: string; +} + +/** + * Sondea hasta que el agente aparezca vivo, o hasta agotar el margen. + * + * Se espera de verdad en lugar de responder en cuanto el comando de arranque retorna: tanto + * `Start-ScheduledTask` como `systemctl start` vuelven enseguida, y el binario es un onefile de + * ~270 MB que tarda en desempacarse. Contestar "arrancado" ahí sería la misma mentira que + * cometía la verificación de la instalación. + */ +async function waitAlive(check: () => Promise): Promise { + const deadline = Date.now() + ALIVE_TIMEOUT_MS; + for (;;) { + if (await check()) return true; + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } +} + +async function startOnWindows(sftp: SftpClient): Promise { + const privileged = await probeWindowsElevation(sftp); + if (privileged.elevation !== 'admin') { + throw new AgentControlError( + 409, + `No se puede arrancar la tarea programada: ${privileged.detail}` + ); + } + + const start = await execRemote( + sftp, + psEncoded( + "$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + + 'if (-not $t) { Write-Output "sin-tarea"; exit 0 }; ' + + "Start-ScheduledTask -TaskName 'CloudRestoreAS'; Write-Output 'arrancada'" + ) + ); + if (start.stdout.trim() === 'sin-tarea') { + throw new AgentControlError( + 409, + 'En el servidor no hay una tarea programada CloudRestoreAS que arrancar. Reinstala ' + + 'desde el panel eligiendo el arranque de servicio.' + ); + } + if (start.code !== 0) { + throw new AgentControlError( + 502, + `Start-ScheduledTask falló: ${start.stderr || start.stdout || 'sin salida'}` + ); + } + + const alive = await waitAlive(async () => { + const proc = await execRemote( + sftp, + psEncoded( + "if (Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue) " + + '{"si"} else {"no"}' + ) + ); + return proc.stdout.trim() === 'si'; + }); + + return alive + ? { ok: true, detail: 'Tarea CloudRestoreAS arrancada y proceso en ejecución.' } + : { + ok: false, + detail: + 'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' + + 'config\\logs en el servidor: el agente está fallando al iniciar.' + }; +} + +/** + * Ruta donde vive el agente, para poder reconocer su proceso. + * + * El ancla `^` del patrón no es opcional: sin ella, el `sh -c` que corre el propio `pgrep` lleva + * la ruta en su línea de comandos y haría match consigo mismo, reportando vivo un agente que + * nunca arrancó. Es el mismo motivo por el que install.sh ancla su patrón. + */ +async function resolveLinuxPrefix(restoreTargetId: number): Promise { + const inventory = await listCrasTargetInventory(); + const row = inventory.find((t) => t.restore_target_id === restoreTargetId); + return effectiveInstallPath(row?.reported_install_path ?? null, 'linux') ?? DEFAULT_INSTALL_PATHS.linux; +} + +async function startOnLinux( + sftp: SftpClient, + privileged: LinuxPrivilege, + prefix: string +): Promise { + const pgrep = `pgrep -f ${shQuote(`^${prefix}/CloudRestoreAS`)} >/dev/null && echo si || echo no`; + const isAlive = async () => (await execRemote(sftp, pgrep)).stdout.trim() === 'si'; + + // Unit de sistema primero: es la instalación recomendada. Si no hay elevación, `prefix` va + // vacío y systemctl fallará solo, sin efectos: ahí se pasa al unit de usuario. + let how = ''; + const system = await execRemote( + sftp, + `${privileged.prefix}systemctl start cloudrestoreas`, + undefined, + privileged.stdin + ); + if (system.code === 0) { + how = `systemctl start cloudrestoreas (elevación: ${privileged.label || 'ninguna'})`; + } else { + // Instalación sin privilegios: el unit vive en el bus del propio usuario. XDG_RUNTIME_DIR + // va explícito porque un `exec` de SSH no es una sesión de login y no siempre lo trae. + const user = await execRemote( + sftp, + 'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user start cloudrestoreas' + ); + if (user.code !== 0) { + throw new AgentControlError( + 502, + 'No se pudo arrancar el servicio ni como unit de sistema ni como unit de usuario. ' + + `Sistema: ${system.stderr || system.stdout || 'sin salida'}. ` + + `Usuario: ${user.stderr || user.stdout || 'sin salida'}.` + ); + } + how = 'systemctl --user start cloudrestoreas'; + } + + const alive = await waitAlive(isAlive); + return alive + ? { ok: true, detail: `${how}: el agente está en ejecución.` } + : { + ok: false, + detail: + `${how} no devolvió error, pero el proceso no apareció. Revisa ` + + '`journalctl -u cloudrestoreas -n 50` en el servidor.' + }; +} + +/** + * Arranca el agente en el destino indicado. Lanza `AgentControlError` con el estado HTTP que + * corresponde cuando el servidor no está en condiciones de que se le pida esto. + */ +export async function startCrasAgent(restoreTargetId: number): Promise { + const target = await getRestoreTargetSsh(restoreTargetId); + if (!target) { + throw new AgentControlError( + 409, + 'El servidor no tiene credenciales SSH completas (host, usuario y contraseña).' + ); + } + + const sftp = new SftpClient(`cras-start-${restoreTargetId}`); + try { + await sftp.connect({ + host: target.ssh_host, + port: target.ssh_port, + username: target.ssh_username, + password: target.ssh_password, + readyTimeout: CONNECT_TIMEOUT_MS + }); + + const system = await probeRemoteSystem(sftp); + if (system.verdict === 'windows') { + return await startOnWindows(sftp); + } + if (system.verdict === 'linux') { + const privileged = await probeLinuxElevation(sftp, target.ssh_password); + const prefix = await resolveLinuxPrefix(restoreTargetId); + return await startOnLinux(sftp, privileged, prefix); + } + throw new AgentControlError( + 409, + `No se pudo determinar el sistema del destino (${system.verdict}). Evidencia: ` + + `${system.evidence}` + ); + } finally { + try { + await sftp.end(); + } catch (err) { + logger.warn({ + message: 'No se pudo cerrar la sesión SFTP de arranque del agente', + context: { + target: target.name, + error: err instanceof Error ? err.message : String(err) + } + }); + } + } +} diff --git a/src/lib/server/cras-verify.ts b/src/lib/server/cras-verify.ts index 1671dac..d965249 100644 --- a/src/lib/server/cras-verify.ts +++ b/src/lib/server/cras-verify.ts @@ -73,13 +73,18 @@ export interface VerifyResult { export interface Remediation { /** Descripción de para qué sirve. */ title: string; - /** Comando exacto a correr EN el servidor destino, para copiar y pegar. */ + /** Comando equivalente EN el servidor destino. Referencia y registro, no la vía principal. */ command: string | null; - /** Dónde correrlo. */ + /** Dónde correrlo, cuando hay que correrlo a mano. */ where: string; /** - * Si el agente podría aplicarlo por su cuenta (corre local, no necesita SSH). Hoy es - * informativo: la ejecución remota desde el panel no está implementada. + * Si el PANEL puede aplicarlo por sí mismo, por la sesión SSH que ya tiene. Cuando es `true` + * la UI ofrece un botón que llama a POST /versiones-cras/agent-start, y el `command` queda + * como referencia para quien quiera hacerlo a mano o auditar qué se ejecutó. + * + * Dejarlo en `false` significa que el remedio está fuera del alcance del panel —capturar + * credenciales, corregir el sudoers, una política de UAC del servidor—, no que no se haya + * implementado. */ agent_could_apply: boolean; notes: string[]; @@ -475,7 +480,11 @@ export async function verifyCrasTarget(restoreTargetId: number): Promise { const checks: VerifyCheck[] = []; const prefix = effectiveInstallPath(reportedInstallPath, 'linux') ?? DEFAULT_INSTALL_PATHS.linux; - // La sonda es la MISMA que usa el instalador. Antes había aquí una copia paralela, y ya - // diferían en la etiqueta: dos pantallas contradiciéndose sobre el mismo hecho. - const elevation = await probeLinuxElevation(sftp); - const privileged = elevation.elevation === 'root' || elevation.elevation === 'sudo-sin-password'; + // La sonda es la MISMA que usa el instalador, y recibe la MISMA contraseña. Antes había aquí + // una copia paralela, y ya diferían en la etiqueta: dos pantallas contradiciéndose sobre el + // mismo hecho. Omitir aquí la contraseña reabriría esa grieta —Verificar diría "sin + // privilegios" de un servidor donde Instalar sí puede elevar—, que es peor que el original + // porque el desacuerdo sería sobre si la instalación va a funcionar. + const elevation = await probeLinuxElevation(sftp, sshPassword); + const privileged = + elevation.elevation === 'root' || + elevation.elevation === 'sudo-sin-password' || + elevation.elevation === 'sudo-con-password'; checks.push( check( 'privilegios', @@ -704,8 +720,8 @@ async function inspectLinux( remediation: { title: 'Arrancar el servicio del agente', command: 'sudo systemctl start cloudrestoreas && sudo systemctl status cloudrestoreas', - where: 'En una terminal del servidor, con privilegios de root.', - agent_could_apply: false, + where: 'El panel puede hacerlo por la sesión SSH que ya tiene.', + agent_could_apply: true, notes: ['Para ver la causa: sudo journalctl -u cloudrestoreas -n 50'] } }; @@ -844,8 +860,12 @@ async function inspectWindows( remediation: { title: 'Arrancar el agente en el servidor', command: 'Start-ScheduledTask -TaskName CloudRestoreAS', - where: 'En PowerShell como Administrador, en el servidor.', - agent_could_apply: false, + // Sin tarea registrada no hay nada que arrancar: el remedio es reinstalar, y + // ofrecer un botón que no puede funcionar es peor que no ofrecerlo. + where: scheduled + ? 'El panel puede hacerlo por la sesión SSH que ya tiene.' + : 'Panel → Versiones CRAS → Instalar, con arranque de servicio.', + agent_could_apply: scheduled, notes: [ `Logs del agente: ${prefix}\\config\\logs`, scheduled ? '' : 'La tarea no está registrada: reinstala desde el panel con arranque de servicio.' diff --git a/src/routes/versiones-cras/+page.svelte b/src/routes/versiones-cras/+page.svelte index d22b4c5..a31d7d3 100644 --- a/src/routes/versiones-cras/+page.svelte +++ b/src/routes/versiones-cras/+page.svelte @@ -254,6 +254,9 @@ verifyResult = null; verifyError = null; copiedKey = null; + // Un resultado de arranque pertenece a la verificación que lo produjo. `startAgent` lo + // vuelve a poner tras re-verificar; cualquier otra verificación debe empezar limpia. + startAgentOutcome = null; try { const res = await fetch(`/versiones-cras/verify?targetId=${targetId}`); const body = await res.json(); @@ -269,6 +272,46 @@ } } + /** Arranque del agente pedido desde el panel: en curso, y cómo terminó. */ + let startingAgent = $state(false); + let startAgentOutcome = $state<{ ok: boolean; detail: string } | null>(null); + + /** + * Aplica el remedio "arrancar el agente" sin salir del panel. + * + * Al terminar se vuelve a verificar en lugar de creerle a la respuesta: lo que interesa es el + * estado del servidor, no que el comando no diera error. `startAgentOutcome` se guarda antes + * de re-verificar porque `verifyTarget` limpia el resultado anterior. + */ + async function startAgent(targetId: number) { + startingAgent = true; + startAgentOutcome = null; + try { + const res = await fetch('/versiones-cras/agent-start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetId }) + }); + const body = await res.json(); + if (!res.ok) { + startAgentOutcome = { + ok: false, + detail: body?.error?.message ?? `El panel respondió ${res.status}` + }; + } else { + startAgentOutcome = { ok: Boolean(body.ok), detail: String(body.detail ?? '') }; + } + } catch (e) { + startAgentOutcome = { ok: false, detail: e instanceof Error ? e.message : String(e) }; + } finally { + startingAgent = false; + } + + const outcome = startAgentOutcome; + await verifyTarget(targetId); + startAgentOutcome = outcome; + } + async function copyValue(key: string, value: string) { try { await navigator.clipboard.writeText(value); @@ -1044,17 +1087,35 @@ {/if} {#if r.agent_could_apply} -

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

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

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

+ {startAgentOutcome.detail} +

+ {/if} +

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

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