/** * Sonda en vivo de un servidor de restauración: responde "¿está disponible AHORA?". * * Es distinta del timestamp de `cloudrestore_status.reported_at`, que solo dice cuándo el * agente arrancó o guardó configuración (no es un heartbeat: se reporta en 3 momentos de * ciclo de vida, ninguno periódico). Un agente sano de hace una semana y un agente muerto de * hace una semana tienen el mismo `reported_at`; esta sonda los distingue. * * **Solo lee.** No instala, no escribe archivos ni reinicia servicios en el destino, así que * es seguro correrla en cualquier momento. * * Lo importante del diseño es el diagnóstico: cuando falla, dice POR QUÉ. Se hace una prueba * TCP cruda antes del handshake SSH para poder separar "el host no responde" de "el puerto * está cerrado porque sshd está detenido", que son dos problemas con remedios opuestos. */ import net from 'node:net'; import SftpClient from 'ssh2-sftp-client'; import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg'; import { execRemote, probeLinuxElevation, psEncoded, readUnitProps, shQuote, unitUserOrRoot } from './cras-install'; import { listCrasTargetInventory } from './cras-releases'; import { DEFAULT_INSTALL_PATHS, effectiveInstallPath, type CrasPlatform } from '$lib/cras-version'; import { logger } from './logger'; const TCP_TIMEOUT_MS = 6_000; const SSH_TIMEOUT_MS = 15_000; const CHECK_TIMEOUT_MS = 20_000; /** Resultado de un chequeo individual. `skipped` = no se pudo evaluar por un fallo previo. */ export type CheckStatus = 'ok' | 'fail' | 'warn' | 'skipped'; export interface VerifyCheck { key: string; label: string; status: CheckStatus; detail: string; } /** Causa raíz clasificada, para poder ofrecer el remedio correcto. */ export type VerifyDiagnosis = | 'ok' | 'sin_credenciales' | 'host_no_responde' | 'puerto_cerrado' | 'credenciales_rechazadas' | 'sin_ejecucion' | 'posix_en_host_windows' | 'sin_privilegios' | 'sin_instalar' | 'servicio_detenido' | 'error'; export interface VerifyResult { restore_target_id: number; name: string; platform: CrasPlatform | null; diagnosis: VerifyDiagnosis; /** Resumen de una línea, listo para la UI. */ summary: string; /** Qué hacer, cuando hay algo que hacer. */ remediation: Remediation | null; checks: VerifyCheck[]; checked_at: string; } export interface Remediation { /** Descripción de para qué sirve. */ title: string; /** Comando exacto a correr EN el servidor destino, para copiar y pegar. */ command: string | null; /** Dónde correrlo. */ 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. */ agent_could_apply: boolean; notes: string[]; } function check(key: string, label: string, status: CheckStatus, detail = ''): VerifyCheck { return { key, label, status, detail }; } /** * Veredicto sobre el sistema del otro lado de la sesión. Nunca es "no sé": las cuatro * combinaciones posibles de las dos pruebas dan una respuesta cerrada y accionable. */ export type SystemVerdict = 'linux' | 'windows' | 'posix_en_host_windows' | 'sin_ejecucion'; export interface SystemProbe { /** La prueba POSIX respondió (hay shell tipo Unix). */ posix: boolean; /** Hay systemd, que es lo que el instalador necesita para el modo servicio. */ systemd: boolean; /** La prueba de PowerShell respondió. */ windows: boolean; /** Existe el registro de tareas programadas (el equivalente de systemd en Windows). */ scheduledTasks: boolean; verdict: SystemVerdict; /** Qué contestó cada prueba, para que la UI no muestre todo con la misma confianza. */ evidence: string; } /** * Identifica el sistema remoto probando **las dos** vías, siempre, sin cascada y sin confiar en * lo que diga la base. * * Se prueban CAPACIDADES y no identidad: al instalador no le importa cómo se llama el sistema, * le importa si puede colocar el binario y registrar el arranque automático. Un Linux sin * systemd es tan inútil para el modo servicio como un Windows sin tareas programadas. * * Por qué no basta encadenar: el chequeo anterior usaba `echo ok`, que **también funciona en * cmd.exe**, así que un Windows pasaba como "tiene shell" y luego se le corrían `id -u`, * `sudo`, `test -x /opt/...` y `systemctl`. Reportaba "sin privilegios, binario no instalado, * servicio detenido" en un servidor perfectamente sano. * * Ninguno de los comandos escribe nada, así que es seguro lanzarlos en cualquier sistema. */ export async function probeRemoteSystem(sftp: SftpClient): Promise { const run = async (command: string): Promise => { try { const r = await execRemote(sftp, command, CHECK_TIMEOUT_MS); return r.code === 0 ? r.stdout.trim() : ''; } catch { // Un fallo aquí es información, no una excepción: significa que esa vía no está. return ''; } }; // POSIX: nombre del kernel y si hay systemd. const posixOut = await run('uname -s 2>/dev/null && (command -v systemctl >/dev/null && echo SYSTEMD || true)'); const posix = /linux|darwin|bsd/i.test(posixOut); const systemd = posixOut.includes('SYSTEMD'); // Windows: versión de PowerShell y si existe Get-ScheduledTask. const winOut = await run( psEncoded( '$v = $PSVersionTable.PSVersion.Major; ' + '$t = if (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue) {"TASKS"} else {""}; ' + 'Write-Output "PS$v $t"' ) ); const windows = /^PS\d/.test(winOut); const scheduledTasks = winOut.includes('TASKS'); let verdict: SystemVerdict; if (posix && windows) verdict = 'posix_en_host_windows'; else if (posix) verdict = 'linux'; else if (windows) verdict = 'windows'; else verdict = 'sin_ejecucion'; const evidence = [ `posix=${posix ? posixOut.split('\n')[0] || 'sí' : 'no responde'}`, `systemd=${systemd ? 'sí' : 'no'}`, `powershell=${windows ? winOut.split(' ')[0] : 'no responde'}`, `tareas=${scheduledTasks ? 'sí' : 'no'}` ].join(', '); return { posix, systemd, windows, scheduledTasks, verdict, evidence }; } /** * Prueba TCP cruda al puerto SSH. Es lo que permite distinguir un host apagado de un sshd * detenido: en el primer caso la conexión expira o no hay ruta, en el segundo el sistema * responde con ECONNREFUSED de inmediato. */ function probeTcp( host: string, port: number ): Promise<{ ok: boolean; code: string | null; message: string }> { return new Promise((resolve) => { const socket = new net.Socket(); let settled = false; const done = (ok: boolean, code: string | null, message: string) => { if (settled) return; settled = true; socket.destroy(); resolve({ ok, code, message }); }; socket.setTimeout(TCP_TIMEOUT_MS); socket.once('connect', () => done(true, null, 'puerto abierto')); socket.once('timeout', () => done(false, 'ETIMEDOUT', 'la conexión expiró')); socket.once('error', (err: NodeJS.ErrnoException) => done(false, err.code ?? null, err.message) ); socket.connect(port, host); }); } /** Traduce el error de la prueba TCP a un diagnóstico y un mensaje entendible. */ function classifyTcpFailure( code: string | null, port: number ): { diagnosis: VerifyDiagnosis; detail: string } { if (code === 'ECONNREFUSED') { // El host está prendido y contestó "no hay nadie escuchando": el servicio SSH está caído. return { diagnosis: 'puerto_cerrado', detail: `el servidor respondió pero nadie escucha en el puerto ${port}: el servicio SSH está detenido` }; } if (code === 'ETIMEDOUT' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') { return { diagnosis: 'host_no_responde', detail: 'el host no respondió: apagado, sin red, o bloqueado por un firewall' }; } if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') { return { diagnosis: 'host_no_responde', detail: 'no se pudo resolver el nombre del host (DNS)' }; } return { diagnosis: 'error', detail: `fallo de red (${code ?? 'desconocido'})` }; } function isAuthFailure(message: string): boolean { const m = message.toLowerCase(); return ( m.includes('authentication') || m.includes('all configured authentication methods failed') || m.includes('permission denied') ); } /** * Remedio para un servicio SSH detenido. El comando se muestra para correrlo a mano en el * servidor: el panel no puede aplicarlo por SSH, porque justamente SSH es lo que está caído. */ function sshdRemediation(platform: CrasPlatform | null): Remediation { if (platform === 'linux') { return { title: 'Reiniciar el servicio SSH en el servidor', command: 'sudo systemctl restart sshd && sudo systemctl status sshd', where: 'En una terminal del servidor, con privilegios de root.', // La unit systemd del agente corre con User=, sin privilegios para // reiniciar sshd; haría falta una regla de sudoers dedicada. agent_could_apply: false, notes: [ 'El panel no puede hacerlo por SSH: SSH es precisamente lo que no responde.', 'El agente CloudRestoreAS tampoco puede: su servicio corre como usuario sin privilegios.', 'Si quieres que el agente lo pueda hacer, hay que darle una regla de sudoers acotada a ese comando.' ] }; } return { title: 'Reiniciar el servicio SSH en el servidor', command: 'Restart-Service sshd', where: 'En PowerShell como Administrador, en el servidor.', // La tarea del agente corre como SYSTEM con RunLevel Highest, así que sí tendría // permisos para reiniciar el servicio si se implementara la remediación asistida. agent_could_apply: true, notes: [ 'El panel no puede hacerlo por SSH: SSH es precisamente lo que no responde.', 'Verifica también que el servicio arranque solo: Set-Service sshd -StartupType Automatic', 'El agente CloudRestoreAS corre como SYSTEM en este servidor, así que sí tendría permisos para aplicarlo él mismo si se habilita la remediación asistida.' ] }; } /** * Verifica un servidor de restauración. Nunca lanza por un fallo del destino: devuelve el * diagnóstico. Solo lanza si el destino no existe. */ export async function verifyCrasTarget(restoreTargetId: number): Promise { const inventory = await listCrasTargetInventory(); const row = inventory.find((t) => t.restore_target_id === restoreTargetId); const name = row?.name ?? `#${restoreTargetId}`; const platform = row?.platform ?? null; const checks: VerifyCheck[] = []; const checkedAt = new Date().toISOString(); const finish = ( diagnosis: VerifyDiagnosis, summary: string, remediation: Remediation | null = null ): VerifyResult => ({ restore_target_id: restoreTargetId, name, platform, diagnosis, summary, remediation, checks, checked_at: checkedAt }); let target: RestoreTargetSsh | null; try { target = await getRestoreTargetSsh(restoreTargetId); } catch (err) { const message = err instanceof Error ? err.message : String(err); checks.push(check('credenciales', 'Credenciales SSH', 'fail', message)); return finish('error', `No se pudieron leer las credenciales de ${name}.`); } if (!target) { checks.push( check( 'credenciales', 'Credenciales SSH', 'fail', 'faltan host, usuario o contraseña SSH' ) ); return finish('sin_credenciales', `${name} no tiene credenciales SSH completas.`, { title: 'Capturar las credenciales SSH del servidor', command: null, where: 'Panel → Servidores de Restauración → editar este servidor.', agent_could_apply: false, notes: ['Sin host, usuario y contraseña SSH el panel no puede verificar ni instalar.'] }); } checks.push( check( 'credenciales', 'Credenciales SSH', 'ok', `${target.ssh_username}@${target.ssh_host}:${target.ssh_port}` ) ); // --- 1) Puerto TCP: separa host caído de sshd detenido -------------------- const tcp = await probeTcp(target.ssh_host, target.ssh_port); if (!tcp.ok) { const { diagnosis, detail } = classifyTcpFailure(tcp.code, target.ssh_port); checks.push(check('tcp', `Puerto ${target.ssh_port} accesible`, 'fail', detail)); for (const key of ['ssh', 'shell', 'privilegios', 'binario', 'servicio', 'config']) { checks.push(check(key, key, 'skipped', 'no se evaluó: no hay conexión')); } const summary = diagnosis === 'puerto_cerrado' ? `${name}: el servidor está prendido pero el servicio SSH está detenido.` : `${name}: el host no responde.`; return finish( diagnosis, summary, diagnosis === 'puerto_cerrado' ? sshdRemediation(platform) : null ); } checks.push(check('tcp', `Puerto ${target.ssh_port} accesible`, 'ok', 'puerto abierto')); // --- 2) Handshake y autenticación SSH ------------------------------------ const sftp = new SftpClient(`cras-verify-${restoreTargetId}`); try { try { await sftp.connect({ host: target.ssh_host, port: target.ssh_port, username: target.ssh_username, password: target.ssh_password, readyTimeout: SSH_TIMEOUT_MS }); } catch (err) { const message = err instanceof Error ? err.message : String(err); const auth = isAuthFailure(message); checks.push( check('ssh', 'Autenticación SSH', 'fail', auth ? 'credenciales rechazadas' : message) ); for (const key of ['shell', 'privilegios', 'binario', 'servicio', 'config']) { checks.push(check(key, key, 'skipped', 'no se evaluó: no hubo sesión')); } return finish( auth ? 'credenciales_rechazadas' : 'error', auth ? `${name}: el servidor SSH respondió pero rechazó las credenciales.` : `${name}: falló la conexión SSH — ${message}`, auth ? { title: 'Actualizar las credenciales SSH', command: null, where: 'Panel → Servidores de Restauración → editar este servidor.', agent_could_apply: false, notes: ['El servicio SSH está arriba; el usuario o la contraseña no coinciden.'] } : null ); } checks.push(check('ssh', 'Autenticación SSH', 'ok', 'sesión establecida')); // --- 3) Identificar el sistema: se prueban AMBOS, siempre ------------ const system = await probeRemoteSystem(sftp); checks.push( check( 'ejecucion', 'Ejecución de comandos', system.verdict === 'sin_ejecucion' ? 'fail' : 'ok', system.evidence ) ); if (system.verdict === 'sin_ejecucion') { for (const key of ['privilegios', 'binario', 'servicio', 'config']) { checks.push( check(key, key, 'skipped', 'no se evaluó: la cuenta no ejecuta comandos') ); } return finish( 'sin_ejecucion', `${name}: la cuenta SSH conecta pero no ejecuta comandos (solo-SFTP o enjaulada).`, { title: 'Usar una cuenta SSH con shell para instalar', command: null, where: 'Configuración de sshd en el servidor, o capturar otra cuenta en el panel.', agent_could_apply: false, notes: [ 'El forward de respaldos funciona igual: para eso basta SFTP.', 'La instalación remota sí necesita ejecutar comandos y privilegios, y puede usar una cuenta distinta de la del forward.', 'Revisa ForceCommand internal-sftp / ChrootDirectory en sshd_config.' ] } ); } // El sistema DETECTADO manda sobre lo que digan la base o el agente: es lo que hay del // otro lado de esta sesión. Si contradicen, se reporta como hallazgo aparte. const detected: CrasPlatform = system.verdict === 'windows' ? 'windows' : 'linux'; const declared = platform; if (declared && declared !== detected) { checks.push( check( 'coherencia', 'Plataforma registrada vs. real', 'fail', `el panel tiene "${declared}" pero el servidor responde como ${detected}: ` + 'el instalador elegiría el artefacto equivocado' ) ); } // Llegar a un POSIX dentro de un host Windows significa que la sesión cae en WSL/Cygwin // y NO en el Windows que corre SQL Server. Instalar ahí dejaría el servicio dentro del // subsistema y las rutas C:\ del .env apuntando a otro sitio. if (system.verdict === 'posix_en_host_windows') { checks.push( check( 'subsistema', 'Destino de la sesión SSH', 'fail', 'responden POSIX y PowerShell: estás llegando a un subsistema (WSL/Cygwin), no al host Windows' ) ); for (const key of ['privilegios', 'binario', 'servicio', 'config']) { checks.push(check(key, key, 'skipped', 'no se evaluó: el destino es ambiguo')); } return finish( 'posix_en_host_windows', `${name}: la sesión SSH entra a un subsistema POSIX dentro de un host Windows, no al host.`, { title: 'Apuntar el SSH al servicio del host Windows', command: null, where: 'Servidores de Restauración → corregir host/puerto SSH de este servidor.', agent_could_apply: false, notes: [ 'Instalar aquí pondría CloudRestoreAS dentro del subsistema: no arrancaría con la máquina.', 'Las rutas C:\\ del config/.env no serían las mismas que ve SQL Server.', 'Verifica que el puerto 22 apunte al sshd de Windows y no al del subsistema.' ] } ); } // --- 4) Privilegios, binario, servicio y configuración --------------- const detail = detected === 'windows' ? await inspectWindows(sftp, row?.reported_install_path ?? null) : await inspectLinux(sftp, row?.reported_install_path ?? null); checks.push(...detail.checks); if (detail.diagnosis !== 'ok') { return finish(detail.diagnosis, `${name}: ${detail.summary}`, detail.remediation); } return finish('ok', `${name}: disponible — ${detail.summary}`); } finally { try { await sftp.end(); } catch (err) { logger.warn({ message: 'No se pudo cerrar la sesión SFTP de verificación', context: { target: name, error: err instanceof Error ? err.message : String(err) } }); } } } interface InspectResult { checks: VerifyCheck[]; diagnosis: VerifyDiagnosis; summary: string; remediation: Remediation | null; } /** * ¿El modo POSIX concede lectura a grupo o a otros? * * Se usa para decidir si un usuario que NO es el dueño del archivo puede leerlo. El caso que * importa es el 0600 que deja una instalación con sudo: solo el dueño, así que un servicio que * corra como otra cuenta no puede leer su propia configuración. * * Acepta 3 o 4 dígitos (el cuarto es el bit de setuid/sticky, que no afecta la lectura). */ export function posixModeAllowsNonOwnerRead(mode: string): boolean { const digits = String(mode ?? '').trim(); if (!/^[0-7]{3,4}$/.test(digits)) return false; const [, group, other] = digits.slice(-3); return (Number(group) & 4) !== 0 || (Number(other) & 4) !== 0; } async function inspectLinux( sftp: SftpClient, reportedInstallPath: string | null ): 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'; checks.push( check( 'privilegios', 'Privilegios para instalar', privileged ? 'ok' : 'warn', privileged ? elevation.label : `${elevation.label} — ${elevation.detail}` ) ); // Qué haría falta para instalar SIN privilegios. Sin esto, el operador solo sabe que le // faltan permisos, no cuál de los dos obstáculos tiene ni si puede rodearlos. const caps = await execRemote( sftp, [ `echo "owner=$(stat -c '%U' ${shQuote(prefix)} 2>/dev/null || echo -)"`, `echo "writable=$(test -w ${shQuote(prefix)} && echo si || echo no)"`, `echo "home_writable=$(test -w "$HOME" && echo si || echo no)"`, 'echo "unit_system=$(test -f /etc/systemd/system/cloudrestoreas.service && echo si || echo no)"', 'echo "unit_user=$(test -f "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/cloudrestoreas.service" && echo si || echo no)"', 'echo "crontab=$(command -v crontab >/dev/null 2>&1 && echo si || echo no)"', 'echo "linger=$(loginctl show-user "$(id -un)" --property=Linger 2>/dev/null | cut -d= -f2)"' ].join('; '), CHECK_TIMEOUT_MS ); const cap = new Map( caps.stdout .split('\n') .map((line) => line.trim().split('=')) .filter((parts) => parts.length === 2) .map(([k, v]) => [k, v] as const) ); const prefixWritable = cap.get('writable') === 'si'; const unitSystem = cap.get('unit_system') === 'si'; checks.push( check( 'ruta-escribible', 'Ruta de instalación escribible', prefixWritable ? 'ok' : 'warn', prefixWritable ? `${prefix} (dueño: ${cap.get('owner') ?? '?'})` : `${prefix} es de ${cap.get('owner') ?? '?'}: actualizar el binario ahí necesita privilegios` ) ); const rootlessViable = cap.get('home_writable') === 'si' && (cap.get('crontab') === 'si' || cap.get('linger') === 'yes'); checks.push( check( 'instalacion-sin-privilegios', 'Instalación sin privilegios posible', rootlessViable ? 'ok' : 'warn', rootlessViable ? `sí: home escribible, ${cap.get('crontab') === 'si' ? 'hay crontab' : 'lingering activo'}` + (unitSystem ? '. Ojo: ya hay un unit de SISTEMA que habría que retirar con root.' : '') : 'no: sin home escribible y sin crontab ni lingering no hay dónde dejarlo arrancado' ) ); const bin = await execRemote( sftp, `test -x ${shQuote(`${prefix}/CloudRestoreAS`)} && echo si || echo no`, CHECK_TIMEOUT_MS ); const installed = bin.stdout.trim() === 'si'; checks.push( check( 'binario', 'Binario instalado', installed ? 'ok' : 'fail', installed ? `${prefix}/CloudRestoreAS` : `no está en ${prefix}` ) ); const version = await execRemote( sftp, `cat ${shQuote(`${prefix}/config/.version`)} 2>/dev/null`, CHECK_TIMEOUT_MS ); const deployed = version.stdout.trim(); // `test -f` comprobaba EXISTENCIA, no lectura, y por eso este check salía verde justo en el // caso roto: una instalación hecha con sudo deja config/.env en 0600 de root mientras el unit // corre como un usuario común, que no puede leerlo. El agente no arranca y la pantalla decía // "Configuración presente: ok". Se comprueba lectura y, además, quién es el dueño frente al // usuario del unit — porque la sonda entra con la cuenta SSH, que no siempre es la del // servicio. const envProbe = await execRemote( sftp, [ `echo "existe=$(test -f ${shQuote(`${prefix}/config/.env`)} && echo si || echo no)"`, `echo "legible=$(test -r ${shQuote(`${prefix}/config/.env`)} && echo si || echo no)"`, `echo "dueno=$(stat -c '%U' ${shQuote(`${prefix}/config/.env`)} 2>/dev/null || echo -)"`, `echo "modo=$(stat -c '%a' ${shQuote(`${prefix}/config/.env`)} 2>/dev/null || echo -)"` ].join('; '), CHECK_TIMEOUT_MS ); const envInfo = new Map( envProbe.stdout .split('\n') .map((line) => line.trim().split('=')) .filter((parts) => parts.length === 2) .map(([k, v]) => [k, v] as const) ); const hasEnv = envInfo.get('existe') === 'si'; const envOwner = envInfo.get('dueno') ?? '-'; const envMode = envInfo.get('modo') ?? '-'; // El unit puede correr como otro usuario que la sesión SSH. Un 0600 solo lo lee su dueño, así // que si el dueño no es el usuario del servicio, el agente no puede leer su configuración // aunque la sonda sí pueda. const unitProps = await readUnitProps(sftp, 'cloudrestoreas', ['User']); const serviceUser = unitUserOrRoot(unitProps); const ownerIsService = envOwner === serviceUser; const serviceCanRead = ownerIsService || posixModeAllowsNonOwnerRead(envMode); checks.push( check( 'config', 'Configuración legible por el servicio', !hasEnv ? 'warn' : serviceCanRead ? 'ok' : 'fail', !hasEnv ? 'falta config/.env' : serviceCanRead ? `${prefix}/config/.env (${envOwner}, ${envMode})` : `${prefix}/config/.env es de '${envOwner}' en modo ${envMode}, pero el ` + `servicio corre como '${serviceUser}': el agente no puede leerlo y no arrancará` ) ); const active = await execRemote( sftp, 'systemctl is-active cloudrestoreas 2>/dev/null || true', CHECK_TIMEOUT_MS ); const state = active.stdout.trim() || 'desconocido'; const running = state === 'active'; checks.push( check('servicio', 'Servicio corriendo', running ? 'ok' : 'fail', `systemd: ${state}`) ); if (!installed) { return { checks, diagnosis: 'sin_instalar', summary: `SSH bien, pero CloudRestoreAS no está instalado en ${prefix}.`, remediation: { title: 'Instalar CloudRestoreAS en este servidor', command: null, where: 'Panel → Versiones CRAS → Instalar.', agent_could_apply: false, notes: privileged ? [] : rootlessViable ? [ `Sin privilegios (${elevation.detail}), pero no hacen falta: elige el ` + 'arranque "Servicio de usuario" al instalar y apunta la ruta al home ' + 'del usuario. Bastan el usuario y la contraseña SSH ya registrados.' ] : [ `Sin privilegios: ${elevation.detail}`, 'Y tampoco es viable la instalación sin privilegios en este servidor.' ] } }; } if (!running) { return { checks, diagnosis: 'servicio_detenido', summary: `instalado (${deployed || 'versión desconocida'}) pero el servicio está ${state}.`, 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, notes: ['Para ver la causa: sudo journalctl -u cloudrestoreas -n 50'] } }; } return { checks, diagnosis: 'ok', summary: `servicio activo, versión ${deployed || 'sin sello'}, en ${prefix}.`, remediation: null }; } async function inspectWindows( sftp: SftpClient, reportedInstallPath: string | null ): Promise { const checks: VerifyCheck[] = []; const prefix = effectiveInstallPath(reportedInstallPath, 'windows') ?? DEFAULT_INSTALL_PATHS.windows; const admin = await execRemote( sftp, psEncoded( '$p=[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent();' + 'if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){"admin"}else{"limitado"}' ), CHECK_TIMEOUT_MS ); const isAdmin = admin.stdout.trim() === 'admin'; checks.push( check( 'privilegios', 'Privilegios para instalar', isAdmin ? 'ok' : 'warn', isAdmin ? 'Administrador' : 'la cuenta SSH no es Administrador' ) ); const bin = await execRemote( sftp, psEncoded(`if (Test-Path -LiteralPath '${prefix}\\CloudRestoreAS.exe') {"si"} else {"no"}`), CHECK_TIMEOUT_MS ); const installed = bin.stdout.trim() === 'si'; checks.push( check( 'binario', 'Binario instalado', installed ? 'ok' : 'fail', installed ? `${prefix}\\CloudRestoreAS.exe` : `no está en ${prefix}` ) ); const version = await execRemote( sftp, psEncoded( `if (Test-Path -LiteralPath '${prefix}\\config\\.version') ` + `{ (Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw).Trim() }` ), CHECK_TIMEOUT_MS ); const deployed = version.stdout.trim(); const env = await execRemote( sftp, psEncoded(`if (Test-Path -LiteralPath '${prefix}\\config\\.env') {"si"} else {"no"}`), CHECK_TIMEOUT_MS ); const hasEnv = env.stdout.trim() === 'si'; checks.push( check( 'config', 'Configuración presente', hasEnv ? 'ok' : 'warn', hasEnv ? `${prefix}\\config\\.env` : 'falta config\\.env' ) ); const task = await execRemote( sftp, psEncoded( "$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + 'if ($t) { $t.State } else { "no-registrada" }' ), CHECK_TIMEOUT_MS ); const state = task.stdout.trim() || 'desconocido'; // Ready = registrada y esperando el disparador; Running = ejecutándose. Ambas cuentan // como "el arranque automático está configurado". const scheduled = state === 'Running' || state === 'Ready'; checks.push( check( 'servicio', 'Arranque automático', scheduled ? 'ok' : 'fail', `tarea CloudRestoreAS: ${state}` ) ); // El proceso vivo es la señal más directa de que está trabajando. const proc = await execRemote( sftp, psEncoded( "$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + 'if ($p) { "corriendo:" + $p.Count } else { "detenido" }' ), CHECK_TIMEOUT_MS ); const procOut = proc.stdout.trim(); const running = procOut.startsWith('corriendo'); checks.push( check('proceso', 'Proceso en ejecución', running ? 'ok' : 'warn', procOut || 'desconocido') ); if (!installed) { return { checks, diagnosis: 'sin_instalar', summary: `SSH bien, pero CloudRestoreAS no está instalado en ${prefix}.`, remediation: { title: 'Instalar CloudRestoreAS en este servidor', command: null, where: 'Panel → Versiones CRAS → Instalar.', agent_could_apply: false, notes: isAdmin ? [] : ['Antes hay que usar una cuenta SSH Administradora: la tarea corre como SYSTEM.'] } }; } if (!scheduled || !running) { return { checks, diagnosis: 'servicio_detenido', summary: `instalado (${deployed || 'versión desconocida'}) pero no está corriendo (tarea: ${state}).`, 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, notes: [ `Logs del agente: ${prefix}\\config\\logs`, scheduled ? '' : 'La tarea no está registrada: reinstala desde el panel con arranque de servicio.' ].filter(Boolean) } }; } return { checks, diagnosis: 'ok', summary: `agente corriendo, versión ${deployed || 'sin sello'}, en ${prefix}.`, remediation: null }; }