feature/interfaz-binarios (#19)
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit

Reviewed-on: #19
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
This commit is contained in:
2026-07-30 13:52:45 +00:00
committed by acazares
parent a9a1dcb9ae
commit 14b611c581
41 changed files with 10355 additions and 29 deletions

View File

@@ -0,0 +1,929 @@
/**
* Instalación y actualización remota de CloudRestoreAS por SSH/SFTP.
*
* El panel empuja el artefacto (que ya tiene cacheado y verificado) al servidor destino y
* corre ahí el instalador que viene dentro del propio paquete. El servidor destino **no
* descarga nada de internet**: el binario es autocontenido y los bytes llegan del panel.
*
* Decisiones de seguridad, tomadas a partir de los defectos del instalador equivalente de
* AServers (backend/api/v1/modules/updates/ssh_install_service.py):
*
* - **Ningún secreto en la línea de comandos.** El token del panel viaja en un archivo 0600
* subido por SFTP, no como argumento: `ps` y el historial del destino son legibles por
* otros usuarios. AServers hace `echo '{password}' | sudo -S ...`, que expone el password
* y además permite inyección de comandos.
* - **Se exige root o `sudo -n`** (sudo sin password). Nunca se le pasa el password a sudo.
* Si no hay privilegios se aborta ANTES de subir 270 MB.
* - **El sha256 se verifica en el destino** antes de extraer, no solo al cachear: así se
* detecta una transferencia corrupta.
* - **El progreso se persiste paso a paso** en cras_install_runs.steps para que la UI lo
* pueda seguir; en AServers los pasos solo quedan en el log del backend.
*/
import fs from 'node:fs/promises';
import SftpClient from 'ssh2-sftp-client';
import type { Client as SshClient } from 'ssh2';
import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg';
import {
appendInstallStep,
finishInstallRun,
getCrasReleaseById,
InstallRunConflictError,
listCrasTargetInventory,
startInstallRun,
type CrasRelease,
type InstallMode
} from './cras-releases';
import { ensureCached } from './cras-artifacts';
import { logger } from './logger';
import {
effectiveArch,
effectivePlatform,
effectiveInstallPath,
isSafeInstallPath,
isInsideWorkFolder
} from '$lib/cras-version';
/** Timeouts. La subida no lleva timeout propio: son cientos de MB por un enlace variable. */
const CONNECT_TIMEOUT_MS = 20_000;
const EXEC_TIMEOUT_MS = 120_000;
/** El bootstrap del binario corre con `timeout 20` del lado del destino; se da margen. */
const INSTALL_EXEC_TIMEOUT_MS = 300_000;
export class InstallError extends Error {
constructor(
public status: number,
message: string
) {
super(message);
this.name = 'InstallError';
}
}
export interface ExecResult {
code: number;
stdout: string;
stderr: string;
}
/**
* Ejecuta un comando por SSH sobre la conexión que ya abrió el cliente SFTP.
*
* `ssh2-sftp-client` envuelve un `ssh2.Client` y lo expone en `.client`, pero sus tipos no
* lo declaran; de ahí el cast. Se reutiliza esa conexión en lugar de abrir una segunda para
* no autenticarse dos veces por instalación.
*/
export function execRemote(
sftp: SftpClient,
command: string,
timeoutMs = EXEC_TIMEOUT_MS
): Promise<ExecResult> {
const conn = (sftp as unknown as { client: SshClient }).client;
if (!conn || typeof conn.exec !== 'function') {
return Promise.reject(new InstallError(500, 'La conexión SSH no expone exec()'));
}
return new Promise<ExecResult>((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
reject(new InstallError(504, `El comando remoto excedió ${timeoutMs / 1000}s`));
}, timeoutMs);
conn.exec(command, (err, stream) => {
if (err) {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(new InstallError(502, `No se pudo ejecutar en el destino: ${err.message}`));
return;
}
let stdout = '';
let stderr = '';
let code = -1;
stream.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
stream.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
stream.on('exit', (exitCode: number | null) => {
code = typeof exitCode === 'number' ? exitCode : -1;
});
stream.on('close', () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
});
});
});
}
/** Comilla simple para sh. Las rutas que se arman son controladas; esto es defensa extra. */
export function shQuote(value: string): string {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
/**
* Envuelve un script de PowerShell como `-EncodedCommand` (base64 UTF-16LE).
*
* En Windows el shell por default de OpenSSH es cmd.exe, y pasar un script con comillas y
* rutas por la línea de comandos se rompe de formas difíciles de depurar. Es el mismo
* patrón que ya usan los Jenkinsfile del org para hospedar comandos en hosts Windows.
*/
export function psEncoded(script: string): string {
const encoded = Buffer.from(script, 'utf16le').toString('base64');
return `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`;
}
export interface InstallRequest {
restoreTargetId: number;
releaseId: number;
mode: InstallMode;
/** Usuario del panel que la disparó, para la bitácora. */
startedBy: string | null;
/** URL base del panel que se sembrará en el .env del destino. */
panelApiUrl: string;
/** Token de servicio que se sembrará en el .env del destino. */
panelApiToken: string;
/** Modo de arranque a registrar en el destino. */
autostart?: 'service' | 'desktop' | 'none';
}
export interface InstallOutcome {
runId: number;
ok: boolean;
version: string;
error?: string;
}
/**
* Instala o actualiza CRAS en un servidor de restauración.
*
* Devuelve cuando terminó (puede tardar varios minutos). El progreso queda en
* cras_install_runs.steps, que la UI consulta por polling.
*/
export async function installCrasOnTarget(request: InstallRequest): Promise<InstallOutcome> {
const release = await getCrasReleaseById(request.releaseId);
if (!release) {
throw new InstallError(404, 'La versión indicada no existe en el catálogo');
}
const target = await getRestoreTargetSsh(request.restoreTargetId);
if (!target) {
throw new InstallError(
409,
'El servidor no tiene credenciales SSH completas (host, usuario y contraseña)'
);
}
// La plataforma del destino debe coincidir con la del artefacto: mandar el .exe a un
// Linux es un error caro de diagnosticar en sitio.
await assertPlatformMatches(target, release);
// Ruta de instalación: la que REPORTÓ el agente, o el default de la plataforma en una
// primera instalación. Nunca se deriva de input_folder ni de remote_inbox_path — esas son
// otras rutas y pueden apuntar a un share en otra máquina. Instalar en la ruta equivocada
// crearía un config/ nuevo con la configuración por omisión y dejaría huérfano el .env
// que el operador personalizó.
const { installPath, reportedInstallPath } = await resolveInstallPath(target, release, request);
if (!request.panelApiUrl.trim() || !request.panelApiToken.trim()) {
throw new InstallError(
500,
'El panel no tiene configurada su URL o su token de servicio; no se puede sembrar la configuración del agente'
);
}
// startInstallRun rechaza si ya hay una instalación corriendo en este destino.
let runId: number;
try {
runId = await startInstallRun({
restoreTargetId: target.id,
releaseId: release.id,
version: release.version,
platform: release.platform,
mode: request.mode,
startedBy: request.startedBy
});
} catch (e) {
// Conflicto de estado, no fallo interno: la UI lo muestra como "ya hay una instalación en
// curso" en lugar de "error interno del panel", que no le dice al operador qué hacer.
if (e instanceof InstallRunConflictError) throw new InstallError(409, e.message);
throw e;
}
logger.info({
message: 'Iniciando instalación remota de CRAS',
context: {
run_id: runId,
target: target.name,
version: release.version,
platform: release.platform,
mode: request.mode
}
});
let sftp: SftpClient | null = null;
try {
await appendInstallStep(runId, 'preparar-artefacto', true, `${release.file_name}`);
const cached = await ensureCached(
release.version,
release.file_name,
release.sha256,
release.file_size
);
await appendInstallStep(
runId,
'artefacto-listo',
true,
cached.downloaded ? 'descargado de Gitea y verificado' : 'ya estaba en caché'
);
sftp = new SftpClient(`cras-install-${runId}`);
await sftp.connect({
host: target.ssh_host,
port: target.ssh_port,
username: target.ssh_username,
password: target.ssh_password,
readyTimeout: CONNECT_TIMEOUT_MS
});
await appendInstallStep(runId, 'conexion-ssh', true, `${target.ssh_username}@${target.ssh_host}`);
await appendInstallStep(runId, 'ruta-de-instalacion', true, installPath);
if (release.platform === 'linux') {
await installLinux(
sftp, runId, release, cached.path, request, target, installPath, reportedInstallPath
);
} else {
await installWindows(
sftp, runId, release, cached.path, request, target, installPath, reportedInstallPath
);
}
await finishInstallRun(runId, 'completed');
logger.info({
message: 'Instalación remota de CRAS completada',
context: { run_id: runId, target: target.name, version: release.version }
});
return { runId, ok: true, version: release.version };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await appendInstallStep(runId, 'error', false, message);
await finishInstallRun(runId, 'failed', message);
logger.error({
message: 'Falló la instalación remota de CRAS',
context: { run_id: runId, target: target.name, version: release.version, error: message }
});
return { runId, ok: false, version: release.version, error: message };
} finally {
if (sftp) {
try {
await sftp.end();
} catch {
// La sesión ya pudo cerrarse sola; no cambia el resultado de la instalación.
}
}
}
}
/**
* La plataforma del artefacto tiene que ser la del destino. Se usa lo que reportó el agente
* y, si aún no hay agente, el texto libre de restore_targets.os. Si no se puede determinar
* se permite continuar: es una primera instalación y el operador ya eligió la versión.
*/
async function assertPlatformMatches(target: RestoreTargetSsh, release: CrasRelease): Promise<void> {
const inventory = await listCrasTargetInventory();
const row = inventory.find((t) => t.restore_target_id === target.id);
const platform = row
? row.platform
: effectivePlatform(null, target.os);
if (platform && platform !== release.platform) {
throw new InstallError(
409,
`El servidor es ${platform} y el artefacto es ${release.platform}. ` +
'Activa o elige la versión de la plataforma correcta.'
);
}
const arch = row ? row.arch : effectiveArch(null);
if (arch && release.arch && arch !== release.arch) {
throw new InstallError(
409,
`El servidor es ${arch} y el artefacto es ${release.arch}.`
);
}
}
/**
* Confirma que el sistema del otro lado de la sesión es el que espera el artefacto.
*
* Antes cada rama probaba solo lo suyo (`uname -s` en Linux, `$PSVersionTable` en Windows), así
* que si la sesión caía en el sistema equivocado el error aparecía a mitad de la instalación —
* o peor, tras subir 270 MB. Probando ambos por adelantado se aborta en segundos.
*
* Detecta además el caso de un POSIX dentro de un host Windows (WSL/Cygwin): instalar ahí
* pondría el agente en el subsistema y no en el Windows que corre SQL Server.
*/
async function assertSystemMatches(
sftp: SftpClient,
expected: CrasRelease['platform']
): Promise<string> {
// Import diferido: cras-verify importa de este módulo (execRemote, psEncoded), así que un
// import estático cerraría el ciclo.
const { probeRemoteSystem } = await import('./cras-verify');
const system = await probeRemoteSystem(sftp);
if (system.verdict === 'sin_ejecucion') {
throw new InstallError(
409,
'La cuenta SSH conecta pero no ejecuta comandos: parece solo-SFTP o enjaulada ' +
'(ForceCommand internal-sftp / ChrootDirectory). La instalación remota necesita ' +
'shell y privilegios, y puede usar una cuenta distinta de la del forward SFTP. ' +
`Evidencia: ${system.evidence}`
);
}
if (system.verdict === 'posix_en_host_windows') {
throw new InstallError(
409,
'La sesión SSH entra a un subsistema POSIX dentro de un host Windows (WSL/Cygwin), ' +
'no al host. Instalar aquí dejaría el agente dentro del subsistema: no arrancaría ' +
'con la máquina y las rutas C:\\ del .env no serían las que ve SQL Server. ' +
`Apunta el SSH al sshd de Windows. Evidencia: ${system.evidence}`
);
}
const detected = system.verdict; // 'linux' | 'windows'
if (detected !== expected) {
throw new InstallError(
409,
`El artefacto es de ${expected} pero el servidor responde como ${detected}. ` +
`Activa o elige la versión de la plataforma correcta. Evidencia: ${system.evidence}`
);
}
// Se verifica la capacidad concreta que necesita el modo servicio, no solo la identidad.
if (detected === 'linux' && !system.systemd) {
throw new InstallError(
409,
'El destino es Linux pero no tiene systemd, así que install.sh --service no puede ' +
`registrar el servicio. Evidencia: ${system.evidence}`
);
}
if (detected === 'windows' && !system.scheduledTasks) {
throw new InstallError(
409,
'El destino es Windows pero no expone Get-ScheduledTask, así que install.ps1 -Service ' +
`no puede registrar la tarea. Evidencia: ${system.evidence}`
);
}
return system.evidence;
}
/**
* Resuelve dónde instalar, con las guardas que evitan crear una segunda instalación.
*
* Prioridad: lo que reportó el agente → el default de la plataforma. **Nunca** se deriva de
* `input_folder` ni de `remote_inbox_path`: son espacios de rutas distintos y el primero puede
* ser un share UNC en otra máquina.
*/
async function resolveInstallPath(
target: RestoreTargetSsh,
release: CrasRelease,
request: InstallRequest
): Promise<{ installPath: string; reportedInstallPath: string | null }> {
const inventory = await listCrasTargetInventory();
const row = inventory.find((t) => t.restore_target_id === target.id);
const reportedInstallPath = row?.reported_install_path ?? null;
const resolved = effectiveInstallPath(row?.reported_install_path ?? null, release.platform);
if (!resolved || !isSafeInstallPath(resolved, release.platform)) {
throw new InstallError(
409,
`La ruta de instalación resuelta no es válida para ${release.platform}: ` +
`${resolved ?? '(ninguna)'}. Revisa lo que reportó el agente.`
);
}
// El binario NO puede vivir dentro de las carpetas de trabajo: el agente vigila Entrada y
// trataría de restaurar sus propios archivos como si fueran respaldos.
const workFolders = row ? [row.input_folder, row.processed_folder] : [];
if (isInsideWorkFolder(resolved, workFolders)) {
throw new InstallError(
409,
`La ruta de instalación (${resolved}) está dentro de una carpeta de trabajo del ` +
'agente. Ahí el propio binario sería tomado por un respaldo a procesar.'
);
}
// En una ACTUALIZACIÓN tiene que existir ya una instalación en esa ruta. Si no, la ruta
// resuelta no es donde vive el agente y estaríamos a punto de crear una segunda con la
// configuración por omisión, dejando huérfano el config/.env que el operador personalizó.
// Esa comprobación se hace con la sesión abierta, en assertExistingInstall().
if (request.mode === 'update' && !reportedInstallPath) {
logger.warn({
message: 'Actualización sin ruta reportada por el agente; se usará el default',
context: { target: target.name, install_path: resolved }
});
}
return { installPath: resolved, reportedInstallPath };
}
/**
* En modo actualización, confirma que en la ruta resuelta haya realmente una instalación
* (existe `config/.env`). Evita el peor escenario: "instalar" en el lugar equivocado, reportar
* éxito, y dejar corriendo la instalación vieja con su configuración mientras la nueva vigila
* carpetas por omisión.
*/
async function assertExistingInstall(
sftp: SftpClient,
platform: CrasRelease['platform'],
installPath: string,
reportedPath: string | null
): Promise<void> {
const cmd =
platform === 'windows'
? psEncoded(`if (Test-Path -LiteralPath '${installPath}\\config\\.env') {"si"} else {"no"}`)
: `test -f ${shQuote(`${installPath}/config/.env`)} && echo si || echo no`;
const result = await execRemote(sftp, cmd);
if (result.stdout.trim() === 'si') return;
throw new InstallError(
409,
`Se pidió ACTUALIZAR pero en ${installPath} no hay una instalación (falta config/.env). ` +
(reportedPath
? `El agente reportó ${reportedPath}. `
: 'El agente no ha reportado su ruta. ') +
'Se aborta para no crear una segunda instalación con la configuración por omisión ' +
'y dejar huérfano el .env personalizado. Si es una instalación nueva, usa Instalar.'
);
}
/** Contenido del archivo de siembra. Solo claves del panel; nada de credenciales SQL. */
function panelEnvContents(request: InstallRequest, instanceKey: string): string {
return [
'# Generado por el PANEL durante la instalación remota. Se borra al consumirse.',
`CLOUDRESTORE_PANEL_API_URL=${request.panelApiUrl.trim().replace(/\/+$/, '')}`,
`CLOUDRESTORE_PANEL_API_TOKEN=${request.panelApiToken.trim()}`,
`CLOUDRESTORE_PANEL_INSTANCE_KEY=${instanceKey}`,
''
].join('\n');
}
// ============================================================================
// Linux
// ============================================================================
async function installLinux(
sftp: SftpClient,
runId: number,
release: CrasRelease,
localPath: string,
request: InstallRequest,
target: RestoreTargetSsh,
installPath: string,
reportedInstallPath: string | null
): Promise<void> {
// --- Precondiciones, antes de transferir nada ---------------------------
// Se identifica el sistema probando AMBAS vías antes de asumir POSIX: si la sesión cae en
// un Windows (o en un WSL dentro de un Windows) hay que abortar aquí y no tras subir 270 MB.
const systemEvidence = await assertSystemMatches(sftp, 'linux');
const privileged = await resolveLinuxPrivilege(sftp, target.ssh_username);
await appendInstallStep(
runId,
'precondiciones',
true,
`${systemEvidence}, privilegios=${privileged.label}`
);
// Antes de transferir: si es una actualización, confirmar que ahí VIVE una instalación.
if (request.mode === 'update') {
await assertExistingInstall(sftp, 'linux', installPath, reportedInstallPath);
}
for (const tool of ['tar', 'sha256sum']) {
const check = await execRemote(sftp, `command -v ${tool}`);
if (check.code !== 0) {
throw new InstallError(409, `El destino no tiene '${tool}', requerido para instalar`);
}
}
const remoteDir = `/tmp/cras-${release.version}-${runId}`;
const remotePkg = `${remoteDir}/${release.file_name}`;
const remoteEnv = `${remoteDir}/panel.env`;
try {
// Se verifica el código de salida: es el primer punto donde se nota que la cuenta no
// puede escribir en /tmp, típicamente por estar enjaulada (ChrootDirectory). Sin este
// chequeo el error aparecía después, como un fallo de transferencia sin causa clara.
const mk = await execRemote(
sftp,
`mkdir -p ${shQuote(remoteDir)} && chmod 700 ${shQuote(remoteDir)}`
);
if (mk.code !== 0) {
throw new InstallError(
409,
`No se pudo preparar ${remoteDir} en el destino (código ${mk.code}: ` +
`${truncate(mk.stderr || mk.stdout) || 'sin salida'}). ` +
'Causa probable: la cuenta SSH está enjaulada o no puede escribir ahí. ' +
'Puedes indicar otra carpeta con CRAS_REMOTE_STAGING_DIR.'
);
}
// --- Transferencia ---------------------------------------------------
const bytes = (await fs.stat(localPath)).size;
await appendInstallStep(runId, 'subir-artefacto', true, `${formatBytes(bytes)}${remotePkg}`);
await sftp.fastPut(localPath, remotePkg);
// --- Verificación de integridad en el destino ------------------------
const sum = await execRemote(sftp, `sha256sum ${shQuote(remotePkg)}`);
const remoteSha = (sum.stdout.split(/\s+/)[0] ?? '').toLowerCase();
if (sum.code !== 0 || remoteSha !== (release.sha256 ?? '').toLowerCase()) {
throw new InstallError(
502,
`El sha256 en el destino no coincide (esperado ${(release.sha256 ?? '').slice(0, 12)}…, ` +
`obtenido ${remoteSha.slice(0, 12) || 'nada'}…). La transferencia se corrompió.`
);
}
await appendInstallStep(runId, 'verificar-sha256', true, 'coincide con el publicado en Gitea');
// --- Siembra de configuración (archivo 0600, nunca por argv) ---------
// Se sube y luego se restringe: `put` no acepta el modo en su tipo de opciones, y
// dejar el token legible por todos aunque sea unos segundos no es aceptable, así
// que el chmod va inmediatamente después y antes de cualquier otro paso.
await sftp.put(Buffer.from(panelEnvContents(request, target.name), 'utf8'), remoteEnv);
await sftp.chmod(remoteEnv, 0o600);
await appendInstallStep(runId, 'sembrar-configuracion', true, 'panel.env 0600 subido');
// --- Extraer y ejecutar el instalador del paquete --------------------
const untar = await execRemote(
sftp,
`tar xzf ${shQuote(remotePkg)} -C ${shQuote(remoteDir)}`,
INSTALL_EXEC_TIMEOUT_MS
);
if (untar.code !== 0) {
throw new InstallError(502, `Falló la extracción: ${untar.stderr || untar.stdout}`);
}
const autostart = request.autostart ?? 'service';
const installerFlags = autostart === 'none' ? '' : ` --${autostart}`;
// PREFIX es variable de entorno en install.sh, no un flag. La ruta no es secreta, así
// que pasarla por la línea de comandos está bien; el token sí va por archivo.
const installCmd =
`cd ${shQuote(`${remoteDir}/CloudRestoreAS`)} && ` +
`${privileged.prefix}env PREFIX=${shQuote(installPath)} ` +
`./install.sh${installerFlags} --panel-env-file ${shQuote(remoteEnv)}`;
await appendInstallStep(runId, 'ejecutar-instalador', true, `install.sh${installerFlags}`);
const install = await execRemote(sftp, installCmd, INSTALL_EXEC_TIMEOUT_MS);
if (install.code !== 0) {
throw new InstallError(
502,
`install.sh terminó con código ${install.code}: ${truncate(install.stderr || install.stdout)}`
);
}
// --- Verificación del despliegue -------------------------------------
await verifyLinuxDeployment(sftp, runId, release, privileged, autostart, installPath);
} finally {
// Limpieza siempre: el panel.env trae el token en claro. Si falla NO se silencia —
// significa que el token se quedó en el servidor y alguien tiene que ir a borrarlo.
await cleanupStaging(sftp, runId, remoteDir, `rm -rf ${shQuote(remoteDir)}`);
}
}
interface LinuxPrivilege {
/** Prefijo a poner delante de los comandos que requieren root. */
prefix: string;
label: string;
}
/**
* Resuelve cómo obtener privilegios: root directo o `sudo -n` (sin password).
*
* Nunca se le pasa el password a sudo por stdin. Si no hay ninguna de las dos vías se
* aborta aquí, antes de transferir el artefacto: fallar tras subir 270 MB es desperdicio y
* deja basura en /tmp del destino.
*/
async function resolveLinuxPrivilege(
sftp: SftpClient,
sshUsername: string
): Promise<LinuxPrivilege> {
const id = await execRemote(sftp, 'id -u');
if (id.code === 0 && id.stdout.trim() === '0') {
return { prefix: '', label: 'root' };
}
const sudo = await execRemote(sftp, 'sudo -n true');
if (sudo.code === 0) {
return { prefix: 'sudo -n ', label: 'sudo sin password' };
}
throw new InstallError(
409,
`El usuario '${sshUsername}' no es root y no tiene sudo sin password. ` +
'Configura NOPASSWD para ese usuario o usa una cuenta root: el instalador necesita ' +
'privilegios para el servicio systemd y, por seguridad, no se le pasa la contraseña a sudo.'
);
}
async function verifyLinuxDeployment(
sftp: SftpClient,
runId: number,
release: CrasRelease,
privileged: LinuxPrivilege,
autostart: 'service' | 'desktop' | 'none',
prefix: string
): Promise<void> {
// El sello config/.version lo escribe el bootstrap del binario; es más confiable que
// stdout de --version, sobre todo por paridad con Windows (console=False).
const stamp = await execRemote(sftp, `cat ${shQuote(`${prefix}/config/.version`)} 2>/dev/null`);
const deployed = stamp.stdout.trim();
if (deployed && deployed !== release.version) {
throw new InstallError(
502,
`El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}`
);
}
await appendInstallStep(
runId,
'verificar-version',
true,
deployed ? `config/.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)'
);
if (autostart !== 'service') return;
const active = await execRemote(sftp, `${privileged.prefix}systemctl is-active cloudrestoreas`);
const state = active.stdout.trim() || active.stderr.trim();
if (state !== 'active') {
throw new InstallError(
502,
`El servicio cloudrestoreas no quedó activo (estado: ${state || 'desconocido'}). ` +
'Revisa journalctl -u cloudrestoreas en el servidor.'
);
}
await appendInstallStep(runId, 'verificar-servicio', true, 'cloudrestoreas active');
}
// ============================================================================
// Windows
// ============================================================================
async function installWindows(
sftp: SftpClient,
runId: number,
release: CrasRelease,
localPath: string,
request: InstallRequest,
target: RestoreTargetSsh,
installPath: string,
reportedInstallPath: string | null
): Promise<void> {
// --- Precondiciones ------------------------------------------------------
const systemEvidence = await assertSystemMatches(sftp, '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"}'
)
);
const autostart = request.autostart ?? 'service';
if (autostart === 'service' && admin.stdout.trim() !== 'admin') {
throw new InstallError(
409,
'El usuario SSH no es Administrador en el destino. La tarea programada ONSTART corre ' +
'como SYSTEM y requiere elevación; usa una cuenta administradora o instala sin servicio.'
);
}
await appendInstallStep(
runId,
'precondiciones',
true,
`${systemEvidence}, privilegios=${admin.stdout.trim()}`
);
if (request.mode === 'update') {
await assertExistingInstall(sftp, 'windows', installPath, reportedInstallPath);
}
// Se usa una carpeta bajo el TEMP del usuario SSH, no C:\Windows\Temp.
const remoteDirPs = `$env:TEMP\\cras-${release.version}-${runId}`;
const tempResolved = await execRemote(sftp, psEncoded(`Write-Output "${remoteDirPs}"`));
const remoteDir = tempResolved.stdout.trim();
if (!remoteDir) {
throw new InstallError(502, 'No se pudo resolver la carpeta temporal del destino');
}
const remotePkg = `${remoteDir}\\${release.file_name}`;
const remoteEnv = `${remoteDir}\\panel.env`;
try {
const mk = await execRemote(
sftp,
psEncoded(`New-Item -ItemType Directory -Path '${remoteDir}' -Force | Out-Null`)
);
if (mk.code !== 0) {
throw new InstallError(
502,
`No se pudo crear ${remoteDir}: ${truncate(mk.stderr || mk.stdout) || 'sin salida'}`
);
}
// --- Transferencia ---------------------------------------------------
const bytes = (await fs.stat(localPath)).size;
await appendInstallStep(runId, 'subir-artefacto', true, `${formatBytes(bytes)}${remotePkg}`);
await sftp.fastPut(localPath, remotePkg.replace(/\\/g, '/'));
// --- Verificación de integridad en el destino ------------------------
const sum = await execRemote(
sftp,
psEncoded(`(Get-FileHash -Algorithm SHA256 -LiteralPath '${remotePkg}').Hash`)
);
const remoteSha = sum.stdout.trim().toLowerCase();
if (sum.code !== 0 || remoteSha !== (release.sha256 ?? '').toLowerCase()) {
throw new InstallError(
502,
`El sha256 en el destino no coincide (esperado ${(release.sha256 ?? '').slice(0, 12)}…, ` +
`obtenido ${remoteSha.slice(0, 12) || 'nada'}…). La transferencia se corrompió.`
);
}
await appendInstallStep(runId, 'verificar-sha256', true, 'coincide con el publicado en Gitea');
// --- Siembra de configuración ----------------------------------------
await sftp.put(
Buffer.from(panelEnvContents(request, target.name), 'utf8'),
remoteEnv.replace(/\\/g, '/')
);
// Los permisos POSIX no aplican en NTFS: se restringe el ACL a Administradores y
// SYSTEM para que el token no quede legible por cualquier usuario del servidor.
// Se restringe el DIRECTORIO además del archivo: New-Item hereda el ACL del padre, y
// si el $env:TEMP de la cuenta resuelve a C:\Windows\Temp ese padre es accesible por
// todos. Y se verifica el código de salida: un icacls que falla en silencio dejaría el
// token legible, que es justo lo que se está tratando de evitar.
const acl = await execRemote(
sftp,
psEncoded(
`icacls '${remoteDir}' /inheritance:r ` +
'/grant:r "BUILTIN\\Administrators:(F)" /grant:r "NT AUTHORITY\\SYSTEM:(F)" | Out-Null; ' +
`icacls '${remoteEnv}' /inheritance:r ` +
'/grant:r "BUILTIN\\Administrators:(F)" /grant:r "NT AUTHORITY\\SYSTEM:(F)" | Out-Null'
)
);
if (acl.code !== 0) {
throw new InstallError(
502,
'No se pudo restringir el acceso al archivo con el token del panel ' +
`(icacls código ${acl.code}: ${truncate(acl.stderr || acl.stdout) || 'sin salida'}). ` +
'Se aborta para no dejarlo legible por otros usuarios del servidor.'
);
}
await appendInstallStep(runId, 'sembrar-configuracion', true, 'panel.env con ACL restringido');
// --- Extraer y ejecutar el instalador --------------------------------
const expand = await execRemote(
sftp,
psEncoded(
`Expand-Archive -LiteralPath '${remotePkg}' -DestinationPath '${remoteDir}' -Force`
),
INSTALL_EXEC_TIMEOUT_MS
);
if (expand.code !== 0) {
throw new InstallError(
502,
`Falló Expand-Archive: ${truncate(expand.stderr || expand.stdout) || 'sin salida'}`
);
}
const flag = autostart === 'none' ? '' : autostart === 'service' ? ' -Service' : ' -Desktop';
await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`);
const install = await execRemote(
sftp,
psEncoded(
`& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` +
`-Prefix '${installPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE`
),
INSTALL_EXEC_TIMEOUT_MS
);
if (install.code !== 0) {
throw new InstallError(
502,
`install.ps1 terminó con código ${install.code}: ${truncate(install.stderr || install.stdout)}`
);
}
await verifyWindowsDeployment(sftp, runId, release, autostart, installPath);
} finally {
await cleanupStaging(
sftp,
runId,
remoteDir,
psEncoded(
`Remove-Item -LiteralPath '${remoteDir}' -Recurse -Force -ErrorAction Stop`
)
);
}
}
async function verifyWindowsDeployment(
sftp: SftpClient,
runId: number,
release: CrasRelease,
autostart: 'service' | 'desktop' | 'none',
prefix: string
): Promise<void> {
const stamp = await execRemote(
sftp,
psEncoded(
`if (Test-Path '${prefix}\\config\\.version') ` +
`{ Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw }`
)
);
const deployed = stamp.stdout.trim();
if (deployed && deployed !== release.version) {
throw new InstallError(
502,
`El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}`
);
}
await appendInstallStep(
runId,
'verificar-version',
true,
deployed ? `config\\.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)'
);
if (autostart !== 'service') return;
const task = await execRemote(
sftp,
psEncoded(
"(Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue).State"
)
);
const state = task.stdout.trim();
if (!state) {
throw new InstallError(
502,
'La tarea programada CloudRestoreAS no quedó registrada en el destino.'
);
}
await appendInstallStep(runId, 'verificar-servicio', true, `tarea CloudRestoreAS: ${state}`);
}
// ============================================================================
// Utilidades
// ============================================================================
/**
* Borra el directorio de staging del destino. **No silencia el fallo**: ese directorio
* contiene `panel.env` con el token del panel en claro, así que si no se pudo borrar hay que
* dejarlo asentado —en la bitácora y en el log— con la ruta exacta, para que alguien vaya a
* limpiarlo. Antes se descartaba con `.catch(() => undefined)` y el token quedaba ahí sin que
* nadie se enterara.
*
* Corre dentro de un `finally`, así que nunca lanza: eso enmascararía el error real de la
* instalación.
*/
async function cleanupStaging(
sftp: SftpClient,
runId: number,
remoteDir: string,
command: string
): Promise<void> {
const report = async (detail: string) => {
await appendInstallStep(
runId,
'limpiar-staging',
false,
`quedó pendiente de borrar ${remoteDir} (contiene el token del panel): ${detail}`
);
logger.error({
message: 'No se pudo borrar el staging remoto; el token del panel quedó en el destino',
context: { run_id: runId, remote_dir: remoteDir, detail }
});
};
try {
const result = await execRemote(sftp, command);
if (result.code === 0) return;
await report(truncate(result.stderr || result.stdout) || `código ${result.code}`);
} catch (err) {
await report(err instanceof Error ? err.message : String(err));
}
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
}
/** Recorta la salida de un comando para la bitácora sin llenar la BD de ruido. */
function truncate(text: string, max = 600): string {
const clean = String(text ?? '').trim();
return clean.length > max ? `${clean.slice(0, max)}` : clean;
}