Files
PANEL_BASES_ANEXO24/src/lib/server/cras-verify.ts
hreyes 2bbbeff3ed
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
fix/instalacion-desatendida (#23)
Reviewed-on: #23
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
2026-07-31 15:00:05 +00:00

883 lines
35 KiB
TypeScript

/**
* 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 equivalente EN el servidor destino. Referencia y registro, no la vía principal. */
command: string | null;
/** Dónde correrlo, cuando hay que correrlo a mano. */
where: string;
/**
* 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[];
}
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<SystemProbe> {
const run = async (command: string): Promise<string> => {
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=<servicio>, 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<VerifyResult> {
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,
target.ssh_password
);
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,
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, 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',
'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: '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']
}
};
}
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<InspectResult> {
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',
// 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.'
].filter(Boolean)
}
};
}
return {
checks,
diagnosis: 'ok',
summary: `agente corriendo, versión ${deployed || 'sin sello'}, en ${prefix}.`,
remediation: null
};
}