/** * 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. * - **El password de sudo va por el stdin del canal `exec`, nunca por argv.** Es la distinción * que importa, y la que el defecto de AServers no hace: lo que expone `echo '{pw}' | sudo -S` * es que la contraseña acaba en la línea de comandos del `sh -c` que la ejecuta, visible con * `ps` para cualquier usuario del destino. Escribirla en el stdin del canal no pasa por * ningún argv, ningún historial ni ningún proceso intermedio, y al no concatenarse a un * comando tampoco abre la puerta a inyección. Es la MISMA credencial con la que ya se * autenticó la sesión SSH, así que no amplía la superficie: el residuo que queda es que vive * en memoria del proceso del panel (ya vivía) y que sudo asienta el intento en el log de auth * del destino. A cambio, instalar en /opt con servicio de sistema deja de exigir que alguien * toque el sudoers del servidor. * - **La elevación se agota en orden, y solo entonces se rinde:** root → `sudo -n` → * `sudo -S` con la credencial del panel → actualización en sitio (que no eleva nada) → * `user-service` bajo el home. Abortar antes de agotarlas devolvía al operador a la consola * del servidor, que es justo lo que este módulo existe para evitar. * - **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 type { AutostartMode } from '$lib/cras-install-progress'; 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; /** * Prefijo de elevación cuando sudo pide contraseña. La contraseña NO va aquí: viaja por el stdin * del canal (`-S`), y este prefijo es lo único que se concatena al comando, así que el argv del * destino nunca la contiene. Ver `probeLinuxElevation` para el porqué de `-k` y `-p ''`. */ const SUDO_PASSWORD_PREFIX = "sudo -S -k -p '' "; 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. * * `stdin` es el canal por el que viaja la contraseña de sudo (ver `probeLinuxElevation`). Es un * parámetro aparte —y no algo que el llamador pueda concatenar a `command`— justamente para que * un secreto no pueda acabar en la línea de comandos: el argv del destino es legible por * cualquier usuario con `ps`. Nada de lo que se escriba aquí se registra en logs ni en los pasos * del run. Se cierra el canal inmediatamente después de escribir: sudo consume su línea y el * proceso real no hereda nada del resto. */ export function execRemote( sftp: SftpClient, command: string, timeoutMs = EXEC_TIMEOUT_MS, stdin?: string ): Promise { 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((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; } // Solo se toca stdin cuando hay algo que mandar: cerrarlo siempre cambiaría el // contrato de las decenas de comandos que hoy lo dejan abierto. if (stdin !== undefined) { stream.write(stdin); stream.end(); } 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?: AutostartMode; } 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 { 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 { 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 { // 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 conocida (el agente no la reporta y no se capturó a mano); ' + '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 { 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 —solo lo hace desde 1.1.0—, así que se usó la ' + 'de omisión. Si la instalación vive en otra carpeta, captúrala con el lápiz que ' + 'está junto al nombre del servidor y vuelve a intentar. ') + '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 { // --- 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'); // El modo `user-service` instala en el home con un unit de usuario, así que no necesita // ninguna elevación. Los modos de sistema sí, y para ellos se agotan TODAS las vías — // incluida la contraseña SSH que el panel ya tiene— antes de rendirse. Lo que no se hace es // fallar después de transferir 270 MB: la decisión se toma aquí, con la sesión abierta. const autostartMode = request.autostart ?? 'service'; const privileged = await probeLinuxElevation(sftp, target.ssh_password); const canElevate = privileged.elevation === 'root' || privileged.elevation === 'sudo-sin-password' || privileged.elevation === 'sudo-con-password'; // Sin ninguna forma de elevar quedan dos vías antes de rendirse. `user-service` instala en el // home; la actualización en sitio deja el unit como está y solo reemplaza el binario, que es // lo único que una actualización necesita de verdad. let inPlaceUpdate = false; if (!canElevate && autostartMode !== 'user-service') { if (request.mode !== 'update') { throw new InstallError( 409, `El usuario '${target.ssh_username}' no puede elevar privilegios: ` + `${privileged.detail} Una instalación NUEVA en ${installPath} necesita ` + 'privilegios para registrar el servicio de sistema. Usa el arranque ' + '"Servicio de usuario", que instala en el home y no los necesita.' ); } const inPlace = await probeInPlaceUpdate(sftp, installPath, target.ssh_username); if (!inPlace.ok) { throw new InstallError( 409, `No se puede actualizar ${target.name} sin privilegios: ${inPlace.reason}` ); } inPlaceUpdate = true; } await appendInstallStep( runId, 'precondiciones', true, `${systemEvidence}, privilegios=${privileged.label}` + (inPlaceUpdate ? ', vía=actualización en sitio sin privilegios' : '') ); // 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'; // En la vía sin privilegios se usa --update-in-place, que no toca /etc ni systemctl: // solo reemplaza el binario y señaliza al proceso para que Restart=always lo relevante. const installerFlags = inPlaceUpdate ? ' --update-in-place' : 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} (elevación: ${privileged.label || 'ninguna'})` ); // `privileged.stdin` solo trae algo en la vía `sudo -S`, y es lo que sudo consume antes // de ceder el canal. install.sh no lee stdin —sus bucles `read` leen de archivo y su // bootstrap se protege con ` { const id = await execRemote(sftp, 'id -u'); if (id.code === 0 && id.stdout.trim() === '0') { return { prefix: '', label: 'root', elevation: 'root', detail: '' }; } const sudo = await execRemote(sftp, 'sudo -n true'); if (sudo.code === 0) { return { prefix: 'sudo -n ', label: 'sudo sin password', elevation: 'sudo-sin-password', detail: '' }; } if (isRequireTty(sudo.stderr)) { return { prefix: '', label: 'sudo bloqueado por requiretty', elevation: 'requiretty', detail: 'el sudoers del destino tiene `Defaults requiretty` y el panel ejecuta sin tty. ' + 'Agregar NOPASSWD NO lo arregla: hay que quitar esa opción o excluir al usuario ' + 'con `Defaults: !requiretty`.' }; } // Sudo pide contraseña, y el panel tiene una: la misma con la que abrió esta sesión SSH. // Se prueba DESPUÉS de `sudo -n` (que no gasta ningún intento) y DESPUÉS de descartar // requiretty (donde sudo rechaza antes de mirar la política, así que la contraseña no // arreglaría nada y solo sumaría un fallo al log de auth del destino). // // `-k` invalida el sello de sudo antes de correr: sin él, una sesión reciente haría pasar la // sonda sin haber comprobado nada, y el comando real fallaría más tarde ya con 270 MB // subidos. `-p ''` deja el prompt vacío para que no se cuele en el stderr que se asienta. if (sshPassword) { const withPassword = await execRemote( sftp, `${SUDO_PASSWORD_PREFIX}true`, EXEC_TIMEOUT_MS, `${sshPassword}\n` ); if (withPassword.code === 0) { return { prefix: SUDO_PASSWORD_PREFIX, stdin: `${sshPassword}\n`, label: 'sudo con la contraseña del panel', elevation: 'sudo-con-password', detail: '' }; } } // `sudo -n true` da falso negativo cuando existe una regla NOPASSWD acotada a comandos // concretos: `true` no está en ella, pero el comando real sí podría estarlo. Listar las // reglas lo distingue, y de paso le dice al operador qué SÍ tiene concedido. const list = await execRemote(sftp, 'sudo -n -l 2>&1 || true'); const scoped = list.code === 0 && /NOPASSWD:/i.test(list.stdout); return { prefix: '', label: 'sin privilegios', elevation: 'ninguna', detail: scoped ? 'tiene reglas NOPASSWD pero acotadas a otros comandos: ' + truncate(list.stdout.replace(/\s+/g, ' ')) : sshPassword ? 'no es root, y su sudo tampoco aceptó la contraseña SSH guardada en el panel: ' + 'o el usuario no está en el sudoers, o la contraseña registrada ya no es la ' + 'vigente en el servidor.' : 'no es root y su sudo pide contraseña.' }; } /** * Propiedades de un unit de systemd, leídas con `systemctl show`, que NO requiere privilegios * (a diferencia de start/stop). * * Vive aquí y se comparte con la sonda de verificación porque ya hubo una divergencia por copiar * esta lógica: la resolución de elevación existía duplicada en cras-verify y en cras-install, y * las dos pantallas acabaron diciendo cosas distintas del mismo servidor. * * `User=` vacío significa root: es lo que systemd asume cuando el unit no lo declara. Tratarlo * como cadena vacía haría creer que el servicio corre como un usuario sin nombre. */ export async function readUnitProps( sftp: SftpClient, unitName: string, props: string[] ): Promise> { const flags = props.map((name) => `-p ${name}`).join(' '); const out = await execRemote(sftp, `systemctl show ${flags} ${unitName} 2>/dev/null`); const parsed = new Map(); for (const line of out.stdout.split('\n')) { const at = line.indexOf('='); if (at <= 0) continue; parsed.set(line.slice(0, at).trim(), line.slice(at + 1).trim()); } return parsed; } /** Usuario efectivo de un unit: `User=` vacío es root, igual que lo interpreta systemd. */ export function unitUserOrRoot(props: Map): string { return props.get('User') || 'root'; } /** * ¿Se puede ACTUALIZAR en su sitio sin privilegios? Devuelve el motivo si no. * * Existe porque hay entornos donde no se usa root en absoluto. Una actualización con el unit ya * registrado no necesita privilegios si se cumplen tres cosas, y las tres se comprueban por SSH * antes de subir 270 MB: * * 1. `install` puede reemplazar el binario. Y no exige permiso sobre el ARCHIVO: a diferencia de * `cp`, desvincula el destino antes de crearlo (por eso `make install` funciona sobre binarios * en ejecución), así que lo que hace falta es escritura en el DIRECTORIO. * 2. El unit corre con el mismo usuario de la sesión SSH, para poder señalizar al proceso. Si * quedó con `User=root` —porque alguien instaló desde un `sudo -i`— esta vía no aplica. * 3. El unit trae `Restart=always`, que es quien vuelve a levantarlo con el binario nuevo. Sin * eso, señalizarlo lo dejaría muerto. */ export async function probeInPlaceUpdate( sftp: SftpClient, installPath: string, sshUsername: string ): Promise<{ ok: boolean; reason: string }> { const writable = await execRemote( sftp, `test -w ${shQuote(installPath)} && echo si || echo no` ); if (writable.stdout.trim() !== 'si') { const owner = await execRemote(sftp, `stat -c '%U' ${shQuote(installPath)} 2>/dev/null`); return { ok: false, reason: `'${sshUsername}' no puede escribir en ${installPath} (dueño: ` + `${owner.stdout.trim() || 'desconocido'}), así que no puede reemplazar el binario.` }; } const props = await readUnitProps(sftp, 'cloudrestoreas', ['User', 'Restart']); const unitUser = unitUserOrRoot(props); if (unitUser !== sshUsername) { return { ok: false, reason: `el servicio corre como '${unitUser}' y la sesión es de '${sshUsername}', así que ` + 'esta cuenta no puede reiniciarlo sin privilegios.' }; } if (props.get('Restart') !== 'always') { return { ok: false, reason: `el unit tiene Restart=${props.get('Restart') || 'desconocido'}; sin Restart=always ` + 'nadie volvería a levantar el agente tras actualizarlo.' }; } // Se mira aquí además de en install.sh para no subir 270 MB en balde. const busy = await execRemote( sftp, `test -d ${shQuote(`${installPath}/Temp`)} && ls -A ${shQuote(`${installPath}/Temp`)} 2>/dev/null | head -1` ); if (busy.stdout.trim()) { return { ok: false, reason: 'hay una restauración en curso. Interrumpirla dejaría ese respaldo vetado para ' + 'siempre y la base en SINGLE_USER, así que se prefiere esperar. Reintenta luego.' }; } return { ok: true, reason: '' }; } async function verifyLinuxDeployment( sftp: SftpClient, runId: number, release: CrasRelease, privileged: LinuxPrivilege, autostart: AutostartMode, // Se llama installPath y no `prefix` a propósito: en el cuerpo convive con // `privileged.prefix`, que es el prefijo de ELEVACIÓN. Dos cosas distintas con el mismo // nombre en el mismo alcance es exactamente donde se cuela un bug silencioso. installPath: string, inPlaceUpdate = false ): Promise { // 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). // // En la vía en sitio se OMITE el bootstrap y el sello lo escribe el proceso al reiniciarse, // así que durante unos segundos sigue teniendo la versión ANTERIOR. Leerlo una sola vez // convertía una actualización correcta en un fallo "reporta 1.0.0, se esperaba 1.1.1", con el // agente ya actualizado y corriendo. Se sondea hasta que coincida, o hasta agotar el margen. let deployed = ''; const stampAttempts = inPlaceUpdate ? 15 : 1; for (let attempt = 0; attempt < stampAttempts; attempt += 1) { if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2000)); const stamp = await execRemote( sftp, `cat ${shQuote(`${installPath}/config/.version`)} 2>/dev/null` ); deployed = stamp.stdout.trim(); // Vacío = todavía no lo escribió, y eso ya se toleraba antes. if (!deployed || deployed === release.version) break; } if (deployed && deployed !== release.version) { throw new InstallError( 502, `El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}. ` + (inPlaceUpdate ? 'El sello no cambió tras reiniciar el agente, así que el proceso puede seguir ' + 'ejecutando el binario anterior.' : '') ); } await appendInstallStep( runId, 'verificar-version', true, deployed ? `config/.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)' ); if (autostart !== 'service' && autostart !== 'user-service') return; // El unit de usuario NO se consulta con el prefijo de elevación: vive en el bus del propio // usuario, y preguntarlo como root apuntaría al bus equivocado y respondería 'inactive' // sobre un servicio que sí está corriendo. XDG_RUNTIME_DIR va explícito porque un `exec` de // SSH no es una sesión de login y no siempre lo trae. // En la vía en sitio el agente acaba de reiniciarse: `is-active` puede devolver 'activating' // durante unos segundos, y eso no es un fallo. install.sh ya confirmó que el proceso volvió, // así que el proceso vivo es la evidencia que manda. const userMode = autostart === 'user-service'; const active = await execRemote( sftp, userMode ? 'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user is-active cloudrestoreas' : `${privileged.prefix}systemctl is-active cloudrestoreas`, EXEC_TIMEOUT_MS, userMode ? undefined : privileged.stdin ); const state = active.stdout.trim() || active.stderr.trim(); if (state !== 'active') { // En modo usuario el arranque puede haber quedado por cron (@reboot) en vez de systemd, // cuando el destino no permite lingering. Ahí no hay unit que consultar y el proceso se // comprueba directamente, que es lo que de verdad importa. if (userMode || inPlaceUpdate) { // El ancla ^ es obligatoria: sin ella el `sh -c` que corre este mismo pgrep lleva la // ruta en su propia línea de comandos y haría match consigo mismo, reportando vivo un // agente que no arrancó. const alive = await execRemote( sftp, `pgrep -f ${shQuote(`^${installPath}/CloudRestoreAS`)} >/dev/null && echo si || echo no` ); if (alive.stdout.trim() === 'si') { await appendInstallStep( runId, 'verificar-servicio', true, inPlaceUpdate ? `proceso vivo con el binario nuevo (unit intacto, estado: ${state})` : 'proceso vivo (arranque por cron; systemd de usuario no disponible)' ); return; } } throw new InstallError( 502, `El servicio cloudrestoreas no quedó activo (estado: ${state || 'desconocido'}). ` + (userMode ? 'Revisa `systemctl --user status cloudrestoreas` en el servidor.' : 'Revisa journalctl -u cloudrestoreas en el servidor.') ); } await appendInstallStep( runId, 'verificar-servicio', true, userMode ? 'cloudrestoreas active (systemd de usuario)' : 'cloudrestoreas active' ); } // ============================================================================ // Windows // ============================================================================ export type WindowsElevation = 'admin' | 'token-filtrado' | 'limitado' | 'desconocido'; export interface WindowsPrivilege { elevation: WindowsElevation; label: string; /** Qué falta exactamente, en términos accionables. Vacío cuando sí hay elevación. */ detail: string; } /** * Modo de lenguaje de PowerShell en el destino. * * Existe porque en `ConstrainedLanguage` —lo que imponen AppLocker o WDAC, habituales en servidores * endurecidos— PowerShell prohíbe resolver tipos .NET y llamar a métodos estáticos, mientras los * cmdlets y `& script.ps1` siguen funcionando. Eso convierte fallos en sitios sorprendentes: fue la * hipótesis principal cuando `(Get-Command …).Parameters` respondía en una máquina de desarrollo y * no en el servidor real, y `install.ps1` todavía usa varias construcciones que ahí no funcionarían * (`[System.IO.Path]::GetFullPath`, `New-Object System.Text.UTF8Encoding`, `[TimeSpan]::Zero`…). * * Se registra en la bitácora del run SIEMPRE, cueste lo que cueste averiguarlo, para dejar de * diagnosticar a ciegas: sin este dato cada fallo raro en Windows arranca con una ronda de * suposiciones. Devuelve cadena vacía si ni siquiera esto se pudo consultar. */ export async function probeWindowsLanguageMode(sftp: SftpClient): Promise { const probe = await execRemote( sftp, psEncoded('Write-Output $ExecutionContext.SessionState.LanguageMode') ); return probe.code === 0 ? probe.stdout.trim() : ''; } /** * Sonda de elevación en Windows. * * Distingue tres estados donde antes había dos, y la distinción que se añade es la que importa: * una cuenta que ESTÁ en Administradores puede recibir de OpenSSH el token **filtrado** por UAC, * y entonces `IsInRole(Administrator)` responde que no. El panel reportaba eso como "el usuario * SSH no es Administrador" y mandaba a cambiar de cuenta — pero la cuenta ya era la correcta, y * cambiarla no arregla nada. Lo que falta ahí es una política del servidor. * * La pertenencia al grupo se lee del token (SID S-1-5-32-544). En un token filtrado el grupo * sigue presente, marcado como deny-only, que es justo lo que permite separar los dos casos. */ export async function probeWindowsElevation(sftp: SftpClient): Promise { const probe = await execRemote( sftp, psEncoded( '$id=[Security.Principal.WindowsIdentity]::GetCurrent();' + '$p=[Security.Principal.WindowsPrincipal]$id;' + "$sid=New-Object Security.Principal.SecurityIdentifier 'S-1-5-32-544';" + '$inGroup=[bool]($id.Groups | Where-Object { $_ -eq $sid });' + 'if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){"admin"}' + 'elseif($inGroup){"token-filtrado"}else{"limitado"}' ) ); const salida = probe.stdout.trim(); // Sin respuesta no se afirma que la cuenta sea limitada: ese veredicto gobierna un 409, y // deducirlo de un comando que no contestó rechazaría instalaciones perfectamente válidas. if (probe.code !== 0 || !salida) { return { elevation: 'desconocido', label: 'privilegios no determinados', detail: `la sonda de privilegios no respondió (código ${probe.code})` + (probe.stderr ? `: ${truncate(probe.stderr)}` : '') + '. Se continúa: el propio instalador fallará con un mensaje claro si de verdad ' + 'faltan permisos.' }; } switch (salida) { case 'admin': return { elevation: 'admin', label: 'administrador', detail: '' }; case 'token-filtrado': return { elevation: 'token-filtrado', label: 'administrador con token filtrado por UAC', detail: 'la cuenta SÍ pertenece a Administradores, pero la sesión SSH recibió el token ' + 'filtrado por UAC, así que no puede mandar sobre una tarea que corre como ' + 'SYSTEM. No hay que cambiar de cuenta: hay que permitir la elevación remota en ' + 'el servidor, poniendo LocalAccountTokenFilterPolicy=1 (DWORD) en ' + 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System.' }; default: return { elevation: 'limitado', label: 'sin privilegios', detail: 'la cuenta SSH no pertenece a Administradores del destino, y la tarea programada ' + 'corre como SYSTEM. Hace falta una cuenta administradora.' }; } } /** * Comparación de rutas de Windows. Una sola implementación, a propósito. * * La pregunta "¿son la misma ruta?" estaba resuelta en tres sitios con tres criterios ligeramente * distintos (una recortaba comillas múltiples, otra una sola, la tercera ninguna). Coincidían en los * casos reales, pero este módulo ya lleva escrito lo que cuesta esa duplicación: `probeLinuxElevation` * e `inspectLinux` tenían copias paralelas y las dos pantallas acabaron diciendo cosas distintas del * mismo servidor. * * Lo que hay que normalizar y por qué: * - **Comillas**: la acción de una tarea programada guarda la ruta entrecomillada. * - **Barras finales**: una ruta capturada a mano suele traerlas. * - **Mayúsculas**: NTFS no las distingue, así que comparar sensible al caso reporta dos rutas * distintas donde el sistema de archivos ve una sola. * * Lo que NO se hace es comparar por prefijo. `C:\Aduanasoft\CloudRestoreAS` es prefijo de cadena de * `C:\Aduanasoft\CloudRestoreAS-win`, así que un `startsWith` daría por iguales dos instalaciones * distintas — y esa combinación existe en producción. */ export function normalizeWindowsPath(value: string): string { return String(value ?? '') .trim() .replace(/^"+|"+$/g, '') .replace(/\\+$/, '') .toLowerCase(); } /** ¿Las dos rutas apuntan al mismo archivo o carpeta? Igualdad exacta tras normalizar. */ export function sameWindowsPath(a: string, b: string): boolean { const na = normalizeWindowsPath(a); const nb = normalizeWindowsPath(b); return na !== '' && na === nb; } /** Ruta del ejecutable del agente dentro de una carpeta de instalación. */ export function windowsAgentExe(prefix: string): string { return `${String(prefix ?? '').trim().replace(/\\+$/, '')}\\CloudRestoreAS.exe`; } export interface WindowsProcessProbe { /** * La sonda obtuvo una respuesta del destino. En false NO se sabe si hay agente corriendo, y * quien decida un fallo duro debe exigir una respuesta afirmativa en vez de asumirla por * ausencia: leer "el comando no respondió" como "no hay proceso" tumba actualizaciones buenas. */ answered: boolean; running: boolean; /** Rutas de los ejecutables vivos. Vacío si corren pero no se pudo leer su ruta. */ paths: string[]; /** Alguno corre el binario del prefijo indicado. */ fromPrefix: boolean; } /** * Procesos del agente en el destino, con la RUTA de su ejecutable. * * El nombre no distingue instalaciones: `Get-Process -Name CloudRestoreAS` responde igual para el * agente que acabamos de poner y para uno viejo que sigue vivo en otra carpeta. * * Si la ruta no es legible se devuelve `paths` vacío y `fromPrefix` en false, y el llamador NO debe * concluir que es de otra instalación: un proceso de SYSTEM no expone su ruta a una cuenta sin * elevación, y tratar eso como fallo rompería actualizaciones correctas. */ export async function probeWindowsAgentProcess( sftp: SftpClient, prefix: string ): Promise { const probe = await execRemote( sftp, psEncoded( "$p = @(Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue); " + "if ($p.Count -eq 0) { Write-Output 'detenido'; return }; " + '$rutas = @(); ' + 'foreach ($x in $p) { try { if ($x.Path) { $rutas += $x.Path } } catch { } }; ' + "Write-Output ('corriendo|' + ($rutas -join ';'))" ) ); const out = probe.stdout.trim(); // Sin respuesta no se sabe nada. Antes esto se leía como "detenido", que es una afirmación que // la sonda nunca hizo. if (probe.code !== 0 || !out) { return { answered: false, running: false, paths: [], fromPrefix: false }; } if (!out.startsWith('corriendo')) { return { answered: true, running: false, paths: [], fromPrefix: false }; } const joined = out.split('|')[1] ?? ''; const paths = joined.split(';').map((p) => p.trim()).filter(Boolean); const wanted = windowsAgentExe(prefix); return { answered: true, running: true, paths, fromPrefix: paths.some((p) => sameWindowsPath(p, wanted)) }; } export interface WindowsTaskProbe { /** La sonda respondió. En false, `exists: false` significa "no sé", no "no hay tarea". */ answered: boolean; exists: boolean; /** Ruta del ejecutable en la acción de la tarea. Es DÓNDE arranca el agente de verdad. */ execute: string; /** Cuenta con la que corre. `SYSTEM` es lo que obliga a elevar para pararla o arrancarla. */ principal: string; } /** * Interroga la tarea programada del agente. * * Existe porque todo el camino de Windows identificaba al agente por NOMBRE mientras que lo único * que se actualiza se identifica por RUTA. `Start-ScheduledTask` ejecuta la ruta registrada en la * acción de la tarea, no la que acabamos de instalar: en cuanto las dos no coinciden se reemplaza * un binario y se arranca otro, sin que nada falle. * * Las propiedades se leen por reflexión porque no toda acción de una tarea es de tipo Exec (una * ComHandler no tiene `Execute`). */ export async function probeWindowsTask(sftp: SftpClient): Promise { const probe = await execRemote( sftp, psEncoded( "$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + "if (-not $t) { Write-Output 'no||'; return }; " + "$exec = ''; " + 'foreach ($a in @($t.Actions)) { ' + "$pr = $a.PSObject.Properties['Execute']; " + "if ($pr -and $pr.Value) { $exec = ([string]$pr.Value).Trim('\"'); break } }; " + "$user = ''; " + "try { if ($t.Principal) { $user = [string]$t.Principal.UserId } } catch { }; " + "Write-Output ('si|' + $exec + '|' + $user)" ) ); const salida = probe.stdout.trim(); if (probe.code !== 0 || !salida) { return { answered: false, exists: false, execute: '', principal: '' }; } const [flag, execute, principal] = salida.split('|'); return { answered: true, exists: flag === 'si', execute: (execute ?? '').trim(), principal: (principal ?? '').trim() }; } /** * Respuesta de una sonda que puede no saber. * * `desconocido` NO es `no`. Confundirlos es lo que dejó bloqueadas las actualizaciones con un * artefacto perfectamente bueno: la sonda anterior colapsaba cualquier fallo —una excepción, un * código de salida distinto de cero, stdout vacío— en "el instalador no lo soporta", y sobre esa * lectura se decidía un 409. Mientras solo elegía entre dos banderas el falso negativo degradaba; * en cuanto gobernó un rechazo, se volvió un candado. */ export interface ProbeAnswer { /** * `sin-archivo` está aparte de `desconocido` a propósito: que el instalador no exista **sí** es * una respuesta definitiva, y de las peores —no hay nada que ejecutar—, mientras `desconocido` * es "no pude leerlo". Colapsarlos hacía que el run siguiera adelante sabiendo ya que no había * instalador, y gastara el resto del intento para acabar culpando al agente. */ answer: 'si' | 'no' | 'desconocido' | 'sin-archivo'; /** Por qué no se pudo determinar. Vacío cuando hay respuesta. */ detail: string; } /** * ¿El `install.ps1` del artefacto declara `-UpdateInPlace`? * * Se pregunta leyendo el TEXTO del script, no pidiéndole a PowerShell que lo compile y exponga sus * parámetros. `(Get-Command …).Parameters` funcionaba en una máquina de desarrollo y fallaba en el * servidor real; el candidato más probable es Constrained Language Mode (AppLocker/WDAC son * habituales en servidores endurecidos), que restringe el acceso a propiedades de objetos .NET * mientras `& install.ps1` sigue funcionando — justo lo que se observaba. Un `-match` sobre el * contenido no compila nada, no toca reflexión y ninguna política lo bloquea. * * Se ancla a la DECLARACIÓN (`[switch]$UpdateInPlace`) y no a una mención suelta, para que un * comentario que nombre el parámetro no dé un falso positivo. */ export async function probeInstallerUpdateSupport( sftp: SftpClient, installerPath: string ): Promise { const probe = await execRemote( sftp, psEncoded( `$p = '${installerPath}'; ` + "if (-not (Test-Path -LiteralPath $p)) { Write-Output 'sin-archivo'; exit 0 }; " + 'try { ' + // Select-String y nada más: un cmdlet puro, sin resolución de tipos .NET. // La versión anterior usaba [regex]::IsMatch, que es una llamada estática y por // tanto de lo primero que bloquea Constrained Language Mode — el modo que se // sospecha en el servidor donde falló `(Get-Command …).Parameters`. Habría // degradado a 'desconocido' en vez de responder, que no rompe nada pero tampoco // sirve de nada. // `-ErrorAction Stop` NO es opcional: Select-String emite un error NO TERMINANTE // cuando no puede leer el archivo, así que sin él el catch no se dispara, -Quiet // devuelve falso y el fallo de lectura se reporta como 'no'. Es decir, exactamente // el defecto que esta función existe para eliminar. Lo destapó probar la ruta de // fallo con PowerShell real, no las pruebas unitarias. "if (Select-String -LiteralPath $p -Pattern '\\[switch\\]\\s*\\$UpdateInPlace' " + '-Quiet -ErrorAction Stop) ' + "{ Write-Output 'si' } else { Write-Output 'no' } } " + "catch { Write-Output ('desconocido|' + $_.Exception.Message) }" ) ); const salida = probe.stdout.trim(); // El código de salida y stderr cuentan: un execRemote que falla deja stdout vacío, y leer eso // como una respuesta es precisamente el error que se está corrigiendo. if (probe.code !== 0 || !salida) { return { answer: 'desconocido', detail: `la sonda no respondió (código ${probe.code})` + (probe.stderr ? `: ${truncate(probe.stderr)}` : '') }; } if (salida === 'si' || salida === 'no') return { answer: salida, detail: '' }; if (salida === 'sin-archivo') { return { answer: 'sin-archivo', detail: `no existe ${installerPath}` }; } return { answer: 'desconocido', detail: salida.startsWith('desconocido|') ? salida.slice(12) : truncate(salida) }; } /** * ¿Se puede desplegar este artefacto en Windows sin romper el servidor? * * Que su `install.ps1` no declare `-UpdateInPlace` significa que el paquete se construyó ANTES de los * arreglos de actualización, y usarlo no es "una actualización peor": es dejar el servidor sin agente. * Ese instalador detiene la tarea y mata los procesos, corre el bootstrap acotado a 20 s —insuficiente * para desempacar un onefile de ~250 MB con el antivirus escaneando, así que muere antes de escribir * `config\.version`—, re-registra la tarea como SYSTEM y la arranca sin comprobar que volviera. Y el * binario del mismo paquete lleva el `runner.py` que ignora `--headless` en Windows, así que como * SYSTEM en la sesión 0 Qt no puede crear su plataforma y el agente muere. Sin agente viejo y sin * agente nuevo. * * Lanza 409 al ACTUALIZAR: negarse y decir por qué es estrictamente mejor que romperlo y explicarlo * después. En una instalación NUEVA devuelve el aviso para asentarlo, porque ahí no hay agente que * perder y negarse solo estorbaría. * * Es una función aparte, y no una comprobación dentro de `installWindows`, porque es una política con * consecuencias: merece nombre propio y prueba propia. */ export function checkWindowsArtifactUpdateSupport( mode: InstallMode, soporte: ProbeAnswer, version: string, targetName: string ): string { if (soporte.answer === 'si') return ''; // Que el instalador NO ESTÉ no es una duda: es el peor de los hechos posibles, porque no hay // nada que ejecutar. Antes se colapsaba en `desconocido` y el run seguía adelante sabiéndolo ya, // gastaba el resto del intento y acababa culpando al agente de no escribir su sello. if (soporte.answer === 'sin-archivo') { throw new InstallError( 502, `El artefacto ${version} se extrajo en ${targetName} pero no dejó el instalador donde ` + `se esperaba: ${soporte.detail}. No hay nada que ` + 'ejecutar, así que no se toca el servidor. Revisa el paso "contenido-staging" de ' + 'este run para ver qué sí quedó en la carpeta temporal.' ); } // No saber NO es motivo para bloquear. Un fallo de diagnóstico no puede impedir el trabajo: se // sigue con el comportamiento anterior y se deja dicho por qué no se pudo determinar, que es el // dato que convierte la próxima sorpresa en un diagnóstico de diez segundos. if (soporte.answer === 'desconocido') { return ( `No se pudo determinar si el install.ps1 de ${version} soporta -UpdateInPlace ` + `(${soporte.detail || 'sin detalle'}). Se instala con el modo de arranque normal en vez ` + 'de bloquear: no saberlo no es lo mismo que saber que no.' ); } const aviso = `El artefacto ${version} trae un install.ps1 anterior a los arreglos de actualización ` + '(no declara -UpdateInPlace).'; if (mode === 'update') { throw new InstallError( 409, `${aviso} Actualizar ${targetName} con él dejaría el servidor SIN agente: ese instalador ` + 'detiene el que está corriendo, cambia la tarea programada a SYSTEM y no comprueba ' + 'que vuelva a arrancar — y su binario no sabe correr headless en Windows, así que ' + 'como SYSTEM no levanta. Publica una versión construida con los arreglos y ' + 'actualiza a esa.' ); } return `${aviso} Se instala igual: no hay un agente en marcha que perder.`; } /** * Alinea la acción de la tarea programada con el binario que se acaba de instalar. * * Esto lo hace el PANEL y no solo el instalador porque un instalador ya publicado no se puede * arreglar hacia atrás: `install.ps1` aprendió a realinear la tarea después de que saliera 1.1.3, * y el script que se ejecuta en el servidor es el que viaja DENTRO del artefacto. Haciéndolo desde * aquí funciona con cualquier versión ya distribuida. * * Devuelve true si tuvo que cambiar algo. Reapunta solo la acción y conserva disparador, principal, * ajustes y argumentos: volver a registrar la tarea desde cero perdería los ajustes que el operador * le haya hecho. */ export async function alignWindowsTask( sftp: SftpClient, runId: number, taskExecute: string, targetPath: string ): Promise { if (!taskExecute) return false; const wanted = windowsAgentExe(targetPath); if (sameWindowsPath(taskExecute, wanted)) return false; // `$argumentos` y no `$args`: `$args` es una variable automática de PowerShell y asignarla // dentro de un bloque de script es un error. const result = await execRemote( sftp, psEncoded( "$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + "if (-not $t) { Write-Output 'sin-tarea'; exit 0 }; " + '$acciones = @(); ' + 'foreach ($a in @($t.Actions)) { ' + " $pe = $a.PSObject.Properties['Execute']; " + ' if (-not $pe) { $acciones += $a; continue }; ' + " $pa = $a.PSObject.Properties['Arguments']; " + " $argumentos = if ($pa -and $pa.Value) { [string]$pa.Value } else { '' }; " + ' if ($argumentos) { $acciones += New-ScheduledTaskAction ' + `-Execute '${wanted}' -Argument $argumentos -WorkingDirectory '${targetPath}' } ` + 'else { $acciones += New-ScheduledTaskAction ' + `-Execute '${wanted}' -WorkingDirectory '${targetPath}' } }; ` + "try { Set-ScheduledTask -TaskName 'CloudRestoreAS' -Action $acciones " + "-ErrorAction Stop | Out-Null; Write-Output 'ok' } " + "catch { Write-Output ('error|' + $_.Exception.Message) }" ) ); const out = result.stdout.trim(); if (out === 'ok') { // Se asienta la ruta ANTERIOR además de la nueva: si el operador declaró mal la ruta de // instalación, este es el dato con el que se deshace el cambio a mano. await appendInstallStep( runId, 'realinear-arranque', true, `la tarea ejecutaba ${taskExecute}; ahora ejecuta ${wanted}` ); return true; } // No se sigue adelante: arrancar a sabiendas el binario de otra carpeta es justo el fallo // silencioso que se está persiguiendo. throw new InstallError( 409, `La tarea programada de ${targetPath} ejecuta ${taskExecute}, no el binario que se acaba de ` + `instalar (${wanted}), y no se pudo corregir` + (out.startsWith('error|') ? `: ${truncate(out.slice(6))}` : ' (no hay tarea registrada)') + '. Sin eso, el arranque automático seguiría levantando la versión anterior.' ); } /** * Reinicia el agente en Windows y espera a que corra el binario de `prefix`. * * Vive en este módulo y no en `cras-agent-control` porque este es el de más abajo: al revés habría * una dependencia circular. `startCrasAgent` lo reutiliza en lugar de tener su propia copia. */ export async function restartWindowsAgent( sftp: SftpClient, prefix: string, timeoutMs = 60_000 ): Promise { const exe = windowsAgentExe(prefix); await execRemote( sftp, psEncoded( "$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + "if ($t) { Stop-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue }; " + "Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue | " + 'Stop-Process -Force -ErrorAction SilentlyContinue; ' + 'Start-Sleep -Seconds 3; ' + // Si hay tarea se arranca por ella (respeta su principal y su entorno); si no, el // proceso suelto. `--headless` hace que el binario elija el plugin Qt offscreen. "if ($t) { Start-ScheduledTask -TaskName 'CloudRestoreAS' } " + `else { Start-Process -FilePath '${exe}' ` + `-ArgumentList '--start-engine','--headless' -WorkingDirectory '${prefix}' ` + '-WindowStyle Hidden }' ), INSTALL_EXEC_TIMEOUT_MS ); const deadline = Date.now() + timeoutMs; let probe = await probeWindowsAgentProcess(sftp, prefix); while (Date.now() < deadline) { // `paths` vacío con el proceso vivo significa "no pude ver desde dónde corre" (un proceso de // SYSTEM no expone su ruta sin elevación): se acepta, no se insiste hasta agotar el margen. if (probe.fromPrefix || (probe.running && probe.paths.length === 0)) return probe; await new Promise((resolve) => setTimeout(resolve, 2000)); probe = await probeWindowsAgentProcess(sftp, prefix); } return probe; } /** * Cola del crash log que el agente escribe cuando no consigue arrancar. * * Se buscan **las tres** ubicaciones que usa `runner.py` (`_crash_log_targets()`), en su mismo orden * de preferencia: junto al ejecutable, `%LOCALAPPDATA%\CloudRestoreAS\crash.log` y * `%TEMP%\CloudRestoreAS-crash.log`. * * Mirar solo junto al ejecutable no bastaba, y era justo el caso que interesa: un agente lanzado por * una tarea que corre como SYSTEM resuelve las otras dos bajo * `C:\Windows\System32\config\systemprofile\`, y ahí es donde quedó la evidencia del arranque que * fallaba. El operador recibía "no arrancó" en lugar de "no arrancó porque X". * * Las variables se expanden EN EL DESTINO y en la sesión del usuario SSH, así que si el agente corre * como SYSTEM su `%LOCALAPPDATA%` no es el mismo: por eso se añade explícitamente el del perfil de * SYSTEM en vez de confiar en la expansión. */ async function readWindowsCrashLog( sftp: SftpClient, prefix: string ): Promise<{ text: string; from: string }> { const candidatos = [ `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS-crash.log`, '$env:LOCALAPPDATA\\CloudRestoreAS\\crash.log', '$env:TEMP\\CloudRestoreAS-crash.log', // El perfil de SYSTEM, que es donde caen los dos anteriores cuando el agente lo lanza la // tarea programada como SYSTEM y no la sesión SSH. 'C:\\Windows\\System32\\config\\systemprofile\\AppData\\Local\\CloudRestoreAS\\crash.log', 'C:\\Windows\\Temp\\CloudRestoreAS-crash.log' ]; const result = await execRemote( sftp, psEncoded( `$rutas = @("${candidatos.join('","')}"); ` + 'foreach ($r in $rutas) { ' + 'if (Test-Path -LiteralPath $r) { ' + "Write-Output ('DE|' + $r); " + '(Get-Content -LiteralPath $r -Tail 20) -join [Environment]::NewLine; break } }' ) ); const salida = result.stdout.trim(); if (!salida.startsWith('DE|')) return { text: '', from: '' }; const [cabecera, ...resto] = salida.split('\n'); return { text: resto.join('\n').trim(), from: cabecera.slice(3).trim() }; } async function installWindows( sftp: SftpClient, runId: number, release: CrasRelease, localPath: string, request: InstallRequest, target: RestoreTargetSsh, installPath: string, reportedInstallPath: string | null ): Promise { // --- Precondiciones ------------------------------------------------------ const systemEvidence = await assertSystemMatches(sftp, 'windows'); const privileged = await probeWindowsElevation(sftp); const autostart = request.autostart ?? 'service'; // La tarea programada se interroga a fondo, no solo si existe. Su acción dice DÓNDE vive // realmente el agente, y su principal dice si hace falta elevación para tocarla. Sin ese dato // se dio por buena una actualización que reemplazó el binario de una carpeta y arrancó el de // otra: el run terminaba en verde y el servidor seguía con la versión anterior. const task = await probeWindowsTask(sftp); await appendInstallStep( runId, 'arranque-automatico-actual', true, task.exists ? `tarea CloudRestoreAS ejecuta ${task.execute || '(sin acción)'}` + (task.principal ? ` como ${task.principal}` : '') : 'no hay tarea CloudRestoreAS registrada' ); // Cuando el agente no reporta su ruta —nada anterior a 1.1.1 lo hace— la de la tarea es una // fuente REAL del destino, no una suposición. Preferirla al default evita el caso en que el // panel apuntaba a C:\Aduanasoft\CloudRestoreAS mientras el agente vivía en otra carpeta. let targetPath = installPath; if (!reportedInstallPath && task.execute) { const fromTask = task.execute.replace(/\\[^\\]*$/, ''); if (fromTask && isSafeInstallPath(fromTask, 'windows')) { targetPath = fromTask; await appendInstallStep( runId, 'ruta-desde-la-tarea', true, `el agente no reporta su ruta; se usa la de la tarea: ${targetPath}` ); } } // La elevación hace falta para MANDAR sobre la tarea: registrarla en una instalación nueva, o // pararla y arrancarla cuando corre como SYSTEM. Que la tarea EXISTA no basta —si corre con la // propia cuenta SSH no hay nada que elevar—, y tratarlo así bloqueaba de entrada cualquier // actualización sobre un servidor ya instalado. const taskNeedsAdmin = task.exists && /system|S-1-5-18/i.test(task.principal); const needsElevation = autostart === 'service' || taskNeedsAdmin; if (needsElevation && privileged.elevation === 'limitado') { throw new InstallError( 409, `No se puede operar la tarea programada de ${target.name}: ${privileged.detail}` ); } // Con el token filtrado por UAC no se rechaza por adelantado: se intenta y se reporta lo que // responda el servidor. La sonda mide `IsInRole`, que en una sesión de OpenSSH puede decir que // no aun cuando la operación sí pase; adivinarlo bloqueaba actualizaciones que funcionaban. if (needsElevation && privileged.elevation === 'token-filtrado') { await appendInstallStep(runId, 'aviso-elevacion', true, privileged.detail); } // El modo de lenguaje se registra SIEMPRE, aunque todo vaya bien. Es barato y es el dato que // faltaba: en ConstrainedLanguage varias construcciones de install.ps1 no funcionan, y sin // verlo en la bitácora cada fallo raro en Windows empieza con una ronda de suposiciones. const languageMode = await probeWindowsLanguageMode(sftp); await appendInstallStep( runId, 'precondiciones', true, `${systemEvidence}, privilegios=${privileged.label}` + `, PowerShell=${languageMode || 'modo no determinado'}` ); if (languageMode && languageMode !== 'FullLanguage') { await appendInstallStep( runId, 'aviso-modo-restringido', true, `PowerShell corre en ${languageMode}. Ahí se prohíbe resolver tipos .NET, y install.ps1 ` + 'usa varias construcciones que dependen de eso, así que puede fallar en sitios ' + 'inesperados aunque el artefacto sea correcto.' ); } if (request.mode === 'update') { await assertExistingInstall(sftp, 'windows', targetPath, 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 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. // // Se restringe SOLO el archivo, no la carpeta. La restricción de la carpeta era un extra —su // motivo declarado, que el archivo heredara una ACL permisiva al crearse, ya lo cubre la ACE // del propio archivo— y a cambio dejaba la carpeta con ACE **no heredables**, de modo que // todo lo creado dentro después podía quedar sin permisos utilizables. Ahí se extrae el // artefacto, así que era un candidato serio a explicar por qué el instalador "no existía". // // Y por SID, no por nombre de grupo: en un Windows en español `BUILTIN\Administrators` no // resuelve y el icacls falla entero ("No se efectuó ninguna asignación entre los nombres de // cuenta y los identificadores de seguridad"). Comprobado. S-1-5-32-544 es Administradores y // S-1-5-18 es SYSTEM en cualquier idioma. const acl = await execRemote( sftp, psEncoded( `icacls '${remoteEnv}' /inheritance:r ` + '/grant:r "*S-1-5-32-544:(F)" /grant:r "*S-1-5-18:(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'}` ); } // Qué dejó realmente la extracción, asentado en la bitácora. Sin esto no se distinguía // "el instalador no está", "está en otra ruta" y "está pero no se puede leer" — y son tres // arreglos distintos. La sonda decía "no existe el archivo" y no había forma de saber cuál // de los tres era. const listado = await execRemote( sftp, psEncoded( `Get-ChildItem -LiteralPath '${remoteDir}' -Force -ErrorAction SilentlyContinue | ` + 'ForEach-Object { $_.Name } | Sort-Object' ) ); await appendInstallStep( runId, 'contenido-staging', true, listado.stdout.trim().split('\n').map((l) => l.trim()).filter(Boolean).join(', ') || `no se pudo listar ${remoteDir} (código ${listado.code})` ); // Actualizar va por -UpdateInPlace, sea cual sea el arranque configurado: no vuelve a // registrar la tarea (así no pisa ajustes que el operador haya hecho sobre ella), se salta // el bootstrap —una segunda instancia purgaría el Temp\ de la que está viva— y trae respaldo // con reversión automática si el binario nuevo no levanta. // // Pero solo si el instalador DEL ARTEFACTO lo declara: el parámetro existe desde 1.1.3, e // install.ps1 usa [CmdletBinding()], así que pasárselo a uno anterior falla con // NamedParameterNotFound SIN ejecutar una sola línea. const installerPs1 = `${remoteDir}\\CloudRestoreAS\\install.ps1`; const soporte = await probeInstallerUpdateSupport(sftp, installerPs1); const avisoArtefacto = checkWindowsArtifactUpdateSupport( request.mode, soporte, release.version, target.name ); if (avisoArtefacto) { await appendInstallStep(runId, 'instalador-antiguo', true, avisoArtefacto); } const inPlaceUpdate = request.mode === 'update' && soporte.answer === 'si'; const flag = inPlaceUpdate ? ' -UpdateInPlace' : autostart === 'none' ? '' : autostart === 'service' ? ' -Service' : ' -Desktop'; await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`); // El script emite un CENTINELA y el panel lo exige. // // Antes se invocaba con `& '' …; exit $LASTEXITCODE`, y eso no distingue "corrió y // salió 0" de "no llegó a correr": si la invocación falla —porque el archivo no está, por // ejemplo— `$LASTEXITCODE` no se fija nunca (no corrió ningún comando nativo), así que // `exit $null` da 0. Comprobado con PowerShell real. El panel leía 0, asentaba // "Ejecutar instalador ✓", y el servidor seguía intacto con su agente viejo — durante // varios runs, con esa evidencia falsa mandando el diagnóstico a otra parte. // // Con el centinela, la ausencia de CRAS-FIN significa "no terminó", dé lo que dé el código. const install = await execRemote( sftp, psEncoded( "$ErrorActionPreference = 'Stop'; " + 'try { ' + `& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` + `-Prefix '${targetPath}' -PanelEnvFile '${remoteEnv}'; ` + '$c = 0; if ($null -ne $LASTEXITCODE) { $c = $LASTEXITCODE } } ' + "catch { Write-Output ('CRAS-ERROR|' + $_.Exception.Message); exit 90 }; " + "Write-Output ('CRAS-FIN|' + $c); exit $c" ), INSTALL_EXEC_TIMEOUT_MS ); const salidaInstalador = `${install.stdout}\n${install.stderr}`; if (!salidaInstalador.includes('CRAS-FIN|')) { const detalle = salidaInstalador.includes('CRAS-ERROR|') ? salidaInstalador.split('CRAS-ERROR|')[1].split('\n')[0].trim() : truncate(install.stderr || install.stdout) || 'sin salida'; throw new InstallError( 502, `install.ps1 no llegó a terminar en ${target.name}: ${detalle}. El servidor quedó ` + 'como estaba. Revisa el paso "contenido-staging" de este run para ver qué dejó ' + 'la extracción del artefacto.' ); } // 75 = EX_TEMPFAIL, igual que en install.sh: hay una restauración en curso. Es un // conflicto de estado, no un fallo; con el 502 genérico el operador salía a buscar una // avería que no existe. if (install.code === 75) { throw new InstallError( 409, `${target.name} está restaurando un respaldo ahora mismo, así que no se actualizó ` + 'para no interrumpirlo —eso dejaría ese respaldo vetado y la base en ' + 'SINGLE_USER—. Reintenta cuando termine.' ); } if (install.code !== 0) { throw new InstallError( 502, `install.ps1 terminó con código ${install.code}: ${truncate(install.stderr || install.stdout)}` ); } // El instalador puede haber cambiado la CUENTA con la que corre la tarea sin decir nada: // `-Service` la re-registra como SYSTEM. Ese salto de una cuenta con escritorio a SYSTEM en la // sesión 0 es lo que impide arrancar a un binario que no sabe caer a Qt offscreen, así que // cuando pasa hay que dejarlo asentado en vez de que se descubra a base de diagnóstico. const taskDespues = await probeWindowsTask(sftp); if (task.exists && taskDespues.exists && task.principal !== taskDespues.principal) { await appendInstallStep( runId, 'cambio-de-cuenta-de-la-tarea', true, `el instalador cambió la cuenta de la tarea: ${task.principal || '(sin declarar)'} → ` + `${taskDespues.principal || '(sin declarar)'}` ); } // El binario nuevo ya está en targetPath. Ahora hay que asegurar que el arranque automático // apunte AHÍ: el install.ps1 de los artefactos publicados hasta 1.1.3 no sabe realinear la // tarea, y sin eso `Start-ScheduledTask` levanta el binario de la carpeta anterior — la // actualización termina en verde sin haber cambiado nada. if (await alignWindowsTask(sftp, runId, taskDespues.execute || task.execute, targetPath)) { const proc = await restartWindowsAgent(sftp, targetPath); await appendInstallStep( runId, 'rearrancar-agente', proc.running, proc.running ? `corriendo desde ${proc.paths.join(', ') || '(ruta no legible)'}` : 'el agente no volvió a arrancar tras realinear la tarea' ); } await verifyWindowsDeployment( sftp, runId, release, autostart, targetPath, inPlaceUpdate, request.mode === 'update' ); } finally { await cleanupStaging( sftp, runId, remoteDir, psEncoded( `Remove-Item -LiteralPath '${remoteDir}' -Recurse -Force -ErrorAction Stop` ) ); } } export async function verifyWindowsDeployment( sftp: SftpClient, runId: number, release: CrasRelease, autostart: AutostartMode, prefix: string, inPlaceUpdate = false, isUpdate = false ): Promise { // El sello config\.version lo escribe el bootstrap del binario. // // Al actualizar en sitio NO se corre el bootstrap, así que el sello lo escribe el propio // agente al reiniciarse y durante unos segundos sigue teniendo la versión ANTERIOR. Leerlo // una sola vez convertía una actualización correcta en un fallo "reporta 1.1.1, se esperaba // 1.1.2" con el binario ya bien instalado y corriendo. Y en Windows pica más que en Linux: // desempacar un onefile de ~270 MB con Defender escaneando cada archivo se pasa de largo del // margen. Se sondea hasta que coincida, o hasta agotar los intentos. let deployed = ''; // ~5 minutos al actualizar. Los 88s de antes (45 × 2s) se quedaban cortos con un caso real: en // los agentes hasta 1.1.4 el sello se escribía DETRÁS del re-despliegue de las deps embebidas, // que al cambiar de versión se re-copian enteras, y eso va después de desempacar un onefile de // ~254 MB con el antivirus escaneando. Se reportaba como fallida una actualización que iba // bien. Desde 1.1.5 el agente escribe el sello lo primero y esto sobra, pero el margen tiene // que cubrir los artefactos YA publicados, que no se pueden arreglar hacia atrás. // // Agotarlo es un FALLO en una actualización, así que quedarse corto convierte un despliegue // bueno en un error — el error más caro de los dos. const stampAttempts = isUpdate || inPlaceUpdate ? 150 : 3; for (let attempt = 0; attempt < stampAttempts; attempt += 1) { if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2000)); const stamp = await execRemote( sftp, psEncoded( `if (Test-Path '${prefix}\\config\\.version') ` + `{ Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw }` ) ); deployed = stamp.stdout.trim(); // Vacío = todavía no lo escribió, y eso ya se toleraba antes. if (!deployed || deployed === release.version) break; } if (deployed && deployed !== release.version) { throw new InstallError( 502, `El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}. ` + (inPlaceUpdate ? 'El sello no cambió tras reiniciar el agente, así que el proceso puede seguir ' + 'ejecutando el binario anterior.' : '') ); } // En una ACTUALIZACIÓN, un sello ausente es un fallo, no un aviso. // // Esto es lo que dejaba pasar una actualización que no hacía nada. Los agentes anteriores a // 1.1.1 no escribían config\.version, así que `deployed` llegaba vacío, el vacío se toleraba y // el paso se asentaba como "se creará al arrancar": en la única combinación que importa // —actualizar un agente viejo— no se comprobaba ninguna versión. El binario nuevo SÍ lo escribe // en ensure_runtime_layout(), así que si tras todo el margen sigue sin aparecer, lo que corre // no es el binario que acabamos de instalar. La tolerancia se queda solo en instalación nueva, // que es donde sí es legítima. if (!deployed && isUpdate) { // El mensaje trae el diagnóstico ya hecho. El panel tiene a mano a qué apunta la tarea, // desde dónde corre el proceso y el crash log del agente; mandar al operador a "revisar el // arranque automático" le pedía averiguar algo que aquí ya se sabe — y la bitácora del run, // donde sí estaba, no la encuentra nadie. const [task, proc, crash] = await Promise.all([ probeWindowsTask(sftp), probeWindowsAgentProcess(sftp, prefix), readWindowsCrashLog(sftp, prefix) ]); const pistas = [ task.exists ? `el arranque automático ejecuta ${task.execute || '(sin acción)'}` : 'no hay tarea CloudRestoreAS registrada, así que nadie levanta el agente', proc.running ? `hay un proceso corriendo desde ${proc.paths.join(', ') || '(ruta no legible)'}` : 'no hay ningún proceso CloudRestoreAS corriendo' ]; throw new InstallError( 502, `El agente de ${prefix} no escribió config\\.version tras la actualización. La versión ` + `nueva lo escribe al arrancar, así que lo que está corriendo no es ${release.version}. ` + `${pistas.join('; ')}.` + (crash.text ? ` Último crash (${crash.from}): ${truncate(crash.text)}` : '') + ` Logs en ${prefix}\\config\\logs.` ); } await appendInstallStep( runId, 'verificar-version', true, deployed ? `config\\.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)' ); // El proceso vivo es la evidencia que manda, y se comprueba en TODOS los modos. Antes se // salía aquí cuando el arranque no era 'service', así que una actualización que dejaba el // servidor sin agente se asentaba como completada y nadie se enteraba hasta que alguien // pulsaba Verificar. install.ps1 ya garantiza el rearranque; esto lo confirma desde fuera. const proc = await probeWindowsAgentProcess(sftp, prefix); const running = proc.running; // Corriendo, sí, pero ¿el binario de ESTA instalación? Preguntar solo por el nombre respondía // "hay un proceso llamado CloudRestoreAS", que el agente viejo —nunca detenido, arrancado por // una tarea que apuntaba a otra carpeta— satisfacía igual de bien. if (running && proc.paths.length > 0 && !proc.fromPrefix) { throw new InstallError( 502, `Hay un agente corriendo desde ${proc.paths.join(', ')}, no desde ${prefix}. Se instaló ` + 'el binario nuevo en una carpeta y el arranque automático levanta el de otra, así que ' + 'la actualización no surtió efecto. Revisa a qué ejecutable apunta la tarea ' + 'CloudRestoreAS en el servidor.' ); } if (autostart !== 'service') { // Sin tarea que consultar, el proceso es lo único que hay. En 'desktop' el agente arranca // al iniciar sesión, así que no tenerlo vivo ahora no es un fallo; en el resto sí importa // y se asienta tal cual para que la bitácora no mienta. await appendInstallStep( runId, 'verificar-proceso', running || autostart === 'desktop', running ? `CloudRestoreAS en ejecución${proc.paths.length ? ` desde ${proc.paths.join(', ')}` : ''}` : 'CloudRestoreAS no está en ejecución' ); 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.' ); } // `Ready` es una tarea registrada que NO está corriendo: es exactamente lo que se ve cuando // el agente arrancó y murió a los pocos segundos. Aceptar cualquier estado no vacío hacía // pasar por buena una instalación que dejaba el servidor sin restaurador. if (!running) { throw new InstallError( 502, `La tarea CloudRestoreAS quedó registrada (estado: ${state}) pero el proceso no está ` + `corriendo, así que el agente no levantó. Revisa ${prefix}\\config\\logs en el ` + 'servidor.' ); } await appendInstallStep( runId, 'verificar-servicio', true, `tarea CloudRestoreAS: ${state}, proceso en ejecución` + (proc.paths.length ? ` desde ${proc.paths.join(', ')}` : ' (ruta no legible)') ); } // ============================================================================ // 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 { 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; }