Dos huecos de diagnóstico que hacían pasar por sano lo que no lo estaba. 1. El check `config` usaba `test -f`, que comprueba existencia y no lectura, así que salía verde exactamente en el caso roto: una instalación con sudo deja config/.env en 0600 de root mientras el unit corre como una cuenta común, que no puede leerlo. El agente no arranca y la pantalla decía "Configuración presente: ok". Ahora se comprueba lectura y, además, el dueño frente al User= del unit — porque la sonda entra con la cuenta SSH, que no siempre es la del servicio, y un `test -r` desde la sesión no responde por el agente. El check se renombra a "Configuración legible por el servicio", que es lo que de verdad mide, y falla nombrando a los dos usuarios para que el remedio sea obvio. El cálculo del bit de lectura octal sale a una función pura probada: 0600 no deja leer a nadie más que al dueño, se mira el bit 4 y no el valor (620 y 611 no son lectura), se ignora el dígito de setuid, y un modo ilegible NO se interpreta como permisivo — asumir lectura cuando stat devuelve '-' sería el error caro. La lectura de propiedades del unit se extrae a un helper compartido con el instalador. Ya hubo una divergencia por copiar esta lógica: la resolución de elevación existía duplicada y las dos pantallas acabaron diciendo cosas distintas del mismo servidor. 2. Ningún rechazo de token de servicio dejaba rastro: los seis endpoints solo logueaban el 500 de "token no configurado". Con el token rotado, todos los agentes quedan mudos —dejan de reportar versión, resolver rutas y registrar resultados— y desde el panel se ve igual que un agente apagado; además el trace_id que se le devuelve al agente no existía del lado servidor, así que era imposible correlacionar. Se agrega un helper que loguea el rechazo con trace_id, ruta e instancia, y distingue "sin header Authorization" de "token no coincide", que son un agente sin configurar y un token rotado: dos problemas con remedios distintos. El token NUNCA se registra, ni un fragmento suyo — es el mismo valor en todos los agentes, así que un prefijo en los logs ya acota el espacio de búsqueda. Hay prueba de eso. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
863 lines
34 KiB
TypeScript
863 lines
34 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 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<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);
|
|
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<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';
|
|
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<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',
|
|
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
|
|
};
|
|
}
|