feat(versiones-cras): arrancar el agente desde el panel, sin comandos a mano

El diagnóstico sin acción no sirve de nada. Verificar sabía detectar "instalado
pero detenido" y respondía con un comando de PowerShell y un botón de Copiar,
dejándole al operador entrar al servidor a pegarlo — aunque el panel ya tuviera
la sesión SSH, las credenciales y la elevación resueltas. `agent_could_apply`
estaba documentado como "informativo: la ejecución remota no está implementada".

Nuevo POST /versiones-cras/agent-start. Solo arranca: no instala, no actualiza y
no detiene nada, así que el peor caso es un servidor que sigue como estaba. Es
POST y no GET porque modifica el destino y una precarga del navegador no debe
dispararlo. Al terminar se re-verifica en lugar de creerle al comando: lo que
importa es el estado del servidor, no que no diera error.

El comando sigue mostrándose, como referencia para quien prefiera hacerlo a mano.
Cuando no hay tarea registrada no se ofrece el botón: ahí el remedio es
reinstalar, y un botón que no puede funcionar es peor que ninguno.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 08:47:17 -06:00
parent d84b4ef5f1
commit 026b109e82
4 changed files with 405 additions and 20 deletions

View File

@@ -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<boolean>): Promise<boolean> {
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<StartAgentOutcome> {
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<string> {
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<StartAgentOutcome> {
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<StartAgentOutcome> {
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)
}
});
}
}
}

View File

@@ -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<VerifyR
const detail =
detected === 'windows'
? await inspectWindows(sftp, row?.reported_install_path ?? null)
: await inspectLinux(sftp, row?.reported_install_path ?? null);
: await inspectLinux(
sftp,
row?.reported_install_path ?? null,
target.ssh_password
);
checks.push(...detail.checks);
if (detail.diagnosis !== 'ok') {
@@ -522,15 +531,22 @@ export function posixModeAllowsNonOwnerRead(mode: string): boolean {
async function inspectLinux(
sftp: SftpClient,
reportedInstallPath: string | null
reportedInstallPath: string | null,
sshPassword: string
): Promise<InspectResult> {
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.'

View File

@@ -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}
<p class="mt-2 rounded border border-amber-400 bg-white px-2 py-1.5 text-xs text-slate-700">
<span class="material-icons-outlined align-middle text-sm">info</span>
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.
<button
type="button"
onclick={() => startAgent(verifyResult!.restore_target_id)}
disabled={startingAgent}
class="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700 disabled:opacity-60"
>
{startingAgent ? 'Arrancando…' : 'Arrancar ahora desde el panel'}
</button>
<p class="mt-1 text-xs text-amber-900">
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.
</p>
{/if}
</div>
{/if}
<!-- Resultado del arranque. Va FUERA del bloque de remedio a propósito: cuando el
arranque funciona, la re-verificación devuelve diagnóstico `ok` y ya no hay
remedio que pintar, así que dentro nunca se vería el caso de éxito. -->
{#if startAgentOutcome}
<p
class="mt-3 rounded border px-2 py-1.5 text-xs {startAgentOutcome.ok
? 'border-emerald-300 bg-emerald-50 text-emerald-900'
: 'border-rose-300 bg-rose-50 text-rose-900'}"
>
{startAgentOutcome.detail}
</p>
{/if}
<p class="mt-3 text-xs text-slate-500">
Sonda de solo lectura. No instala ni reinicia nada en el servidor.
</p>

View File

@@ -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);
}
};