diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index f021d65..43c5df3 100644 --- a/src/lib/server/cras-install.test.ts +++ b/src/lib/server/cras-install.test.ts @@ -28,7 +28,9 @@ import { execRemote, probeInPlaceUpdate, probeLinuxElevation, + probeWindowsAgentProcess, probeWindowsElevation, + probeWindowsTask, psEncoded, shQuote, verifyWindowsDeployment, @@ -419,25 +421,91 @@ describe('probeWindowsElevation', () => { }); }); +/** + * Sondas de Windows que miran la RUTA, no el nombre. + * + * Son la respuesta al fallo silencioso: todo el camino identificaba al agente por NOMBRE, mientras + * que lo único que se actualiza se identifica por RUTA. En cuanto las dos no coincidían se + * reemplazaba un binario y se arrancaba otro, y ni el instalador ni el panel se enteraban. + */ +describe('sondas de ruta en Windows', () => { + function psFake(stdout: string) { + return fakeSftp(() => ({ code: 0, stdout })); + } + + it('probeWindowsTask saca la ruta y la cuenta de la tarea', async () => { + const { sftp } = psFake('si|C:\\Otra\\CloudRestoreAS.exe|SYSTEM'); + expect(await probeWindowsTask(sftp as never)).toEqual({ + exists: true, + execute: 'C:\\Otra\\CloudRestoreAS.exe', + principal: 'SYSTEM' + }); + }); + + it('probeWindowsTask sin tarea registrada', async () => { + const { sftp } = psFake('no||'); + const r = await probeWindowsTask(sftp as never); + expect(r.exists).toBe(false); + expect(r.execute).toBe(''); + }); + + it('probeWindowsAgentProcess distingue la instalación propia de otra carpeta', async () => { + const { sftp } = psFake('corriendo|C:\\Otra\\CloudRestoreAS.exe'); + const r = await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada'); + expect(r.running).toBe(true); + expect(r.fromPrefix).toBe(false); + expect(r.paths).toEqual(['C:\\Otra\\CloudRestoreAS.exe']); + }); + + it('probeWindowsAgentProcess reconoce la propia sin importar mayúsculas', async () => { + // NTFS no distingue caso: comparar sensible reportaría "corre desde otra carpeta" sobre la + // MISMA ruta y haría fallar una actualización correcta. + const { sftp } = psFake('corriendo|c:\\declarada\\cloudrestoreas.exe'); + expect((await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada')).fromPrefix).toBe(true); + }); + + it('vivo con la ruta ilegible no se afirma que sea ajeno', async () => { + // Un proceso de SYSTEM no expone .Path a una cuenta sin elevación. `paths` vacío significa + // "no sé desde dónde corre", y el llamador no debe tratarlo como fallo. + const { sftp } = psFake('corriendo|'); + const r = await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada'); + expect(r.running).toBe(true); + expect(r.paths).toEqual([]); + expect(r.fromPrefix).toBe(false); + }); +}); + /** * Verificación del despliegue en Windows. * - * El defecto que cubre: se daba por buena cualquier tarea con un `State` no vacío. `Ready` es una - * tarea REGISTRADA QUE NO ESTÁ CORRIENDO —exactamente lo que se ve cuando el agente arrancó y - * murió a los pocos segundos—, así que una actualización que dejaba el servidor sin restaurador - * se asentaba como completada y nadie se enteraba hasta que alguien pulsaba Verificar. + * Dos defectos, y el segundo es el que dejaba pasar la actualización que no hacía nada: + * + * 1. Se daba por buena cualquier tarea con un `State` no vacío. `Ready` es una tarea REGISTRADA QUE + * NO ESTÁ CORRIENDO — lo que se ve cuando el agente arrancó y murió a los pocos segundos. + * 2. Un sello `config\.version` ausente se toleraba SIEMPRE. Como los agentes anteriores a 1.1.1 + * no lo escribían, al actualizar uno de esos no se comprobaba ninguna versión. */ describe('verifyWindowsDeployment', () => { const RELEASE = { version: '1.1.2' } as never; + const PREFIJO = 'C:\\CRAS'; + /** Responde a cada script de PowerShell según lo que pregunte. */ - function fakeWindows(opts: { stamp: string; taskState: string; running: boolean }) { + function fakeWindows(opts: { + stamp: string; + taskState: string; + running: boolean; + /** Desde dónde corre. Por omisión, el propio prefijo. */ + procPath?: string; + }) { return fakeSftp((command) => { const encoded = command.split('-EncodedCommand ')[1] ?? ''; const script = Buffer.from(encoded, 'base64').toString('utf16le'); if (script.includes('config\\.version')) return { code: 0, stdout: opts.stamp }; if (script.includes('Get-Process')) { - return { code: 0, stdout: opts.running ? 'corriendo:1' : 'detenido' }; + if (!opts.running) return { code: 0, stdout: 'detenido' }; + const ruta = opts.procPath ?? `${PREFIJO}\\CloudRestoreAS.exe`; + return { code: 0, stdout: `corriendo|${ruta}` }; } if (script.includes('Get-ScheduledTask')) return { code: 0, stdout: opts.taskState }; return { code: 1, stdout: '' }; @@ -475,6 +543,37 @@ describe('verifyWindowsDeployment', () => { verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') ).rejects.toThrow(/reporta la versión 1\.1\.1/); }); + + it('ACTUALIZANDO, un sello ausente es un fallo', async () => { + // El caso real: un agente 1.1.0 no escribía config\.version, así que el sello llegaba vacío, + // el vacío se toleraba y el run terminaba en verde sobre un servidor sin actualizar. + const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: true }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, true) + ).rejects.toThrow(/no escribió config\\\.version/); + }); + + it('INSTALANDO limpio, un sello ausente sigue siendo aceptable', async () => { + // Ahí sí es legítimo: el bootstrap puede no haber alcanzado a escribirlo todavía. + const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: true }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, false) + ).resolves.toBeUndefined(); + }); + + it('un agente corriendo desde OTRA carpeta no cuenta como desplegado', async () => { + // Exactamente el fallo silencioso: se reemplazó el binario de C:\CRAS y el arranque + // automático levantó el de otra ruta. Antes pasaba por bueno. + const { sftp } = fakeWindows({ + stamp: '1.1.2', + taskState: 'Running', + running: true, + procPath: 'D:\\Vieja\\CloudRestoreAS.exe' + }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, true) + ).rejects.toThrow(/D:\\Vieja\\CloudRestoreAS\.exe/); + }); }); /** diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index e61f4d5..2b71c08 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -1100,6 +1100,96 @@ export async function probeWindowsElevation(sftp: SftpClient): 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(); + if (!out.startsWith('corriendo')) { + return { running: false, paths: [], fromPrefix: false }; + } + const joined = out.split('|')[1] ?? ''; + const paths = joined.split(';').map((p) => p.trim()).filter(Boolean); + const wanted = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`.toLowerCase(); + return { + running: true, + paths, + fromPrefix: paths.some((p) => p.replace(/^"|"$/g, '').toLowerCase() === wanted) + }; +} + +export interface WindowsTaskProbe { + 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 [flag, execute, principal] = probe.stdout.trim().split('|'); + return { + exists: flag === 'si', + execute: (execute ?? '').trim(), + principal: (principal ?? '').trim() + }; +} + async function installWindows( sftp: SftpClient, runId: number, @@ -1116,36 +1206,66 @@ async function installWindows( const privileged = await probeWindowsElevation(sftp); const autostart = request.autostart ?? 'service'; - // La elevación hace falta para MANDAR sobre la tarea, que corre como SYSTEM: registrarla en - // una instalación nueva, o pararla y arrancarla en una actualización. Se comprueba si existe - // en lugar de deducirlo del modo, porque un servidor puede tener la tarea puesta aunque esta - // corrida pida otro arranque, y ahí igual hay que poder detenerla. - const taskProbe = await execRemote( - sftp, - psEncoded( - "if (Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue) " + - '{"si"} else {"no"}' - ) + // 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' ); - const taskExists = taskProbe.stdout.trim() === 'si'; - const needsElevation = autostart === 'service' || taskExists; - if (needsElevation && privileged.elevation !== 'admin') { + // 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); + } await appendInstallStep( runId, 'precondiciones', true, - `${systemEvidence}, privilegios=${privileged.label}` + - (taskExists ? ', tarea CloudRestoreAS ya registrada' : '') + `${systemEvidence}, privilegios=${privileged.label}` ); if (request.mode === 'update') { - await assertExistingInstall(sftp, 'windows', installPath, reportedInstallPath); + await assertExistingInstall(sftp, 'windows', targetPath, reportedInstallPath); } // Se usa una carpeta bajo el TEMP del usuario SSH, no C:\Windows\Temp. @@ -1235,11 +1355,34 @@ async function installWindows( ); } - // Actualizar va SIEMPRE 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. - const inPlaceUpdate = request.mode === 'update'; + // 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. Se le pregunta a PowerShell por el + // `param()` del propio script en vez de mantener una tabla de versiones aquí. + const installerPs1 = `${remoteDir}\\CloudRestoreAS\\install.ps1`; + const supportsProbe = await execRemote( + sftp, + psEncoded( + `if ((Get-Command '${installerPs1}').Parameters.ContainsKey('UpdateInPlace')) ` + + "{'si'} else {'no'}" + ) + ); + const supportsInPlace = supportsProbe.stdout.trim() === 'si'; + const inPlaceUpdate = request.mode === 'update' && supportsInPlace; + if (request.mode === 'update' && !supportsInPlace) { + await appendInstallStep( + runId, + 'instalador-sin-update-in-place', + true, + `${release.version} trae un install.ps1 que no soporta -UpdateInPlace; se instala ` + + 'con el modo de arranque normal' + ); + } const flag = inPlaceUpdate ? ' -UpdateInPlace' : autostart === 'none' @@ -1252,7 +1395,7 @@ async function installWindows( sftp, psEncoded( `& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` + - `-Prefix '${installPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE` + `-Prefix '${targetPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE` ), INSTALL_EXEC_TIMEOUT_MS ); @@ -1275,7 +1418,7 @@ async function installWindows( } await verifyWindowsDeployment( - sftp, runId, release, autostart, installPath, inPlaceUpdate + sftp, runId, release, autostart, targetPath, inPlaceUpdate, request.mode === 'update' ); } finally { await cleanupStaging( @@ -1295,7 +1438,8 @@ export async function verifyWindowsDeployment( release: CrasRelease, autostart: AutostartMode, prefix: string, - inPlaceUpdate = false + inPlaceUpdate = false, + isUpdate = false ): Promise { // El sello config\.version lo escribe el bootstrap del binario. // @@ -1306,7 +1450,10 @@ export async function verifyWindowsDeployment( // 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 = ''; - const stampAttempts = inPlaceUpdate ? 15 : 3; + // Margen generoso: desempacar un onefile de ~270 MB con Defender escaneando cada archivo se + // pasa de largo de los 30s que había antes, y agotar el margen ahora es un FALLO en una + // actualización, así que quedarse corto convertiría un despliegue bueno en un error. + const stampAttempts = isUpdate || inPlaceUpdate ? 45 : 3; for (let attempt = 0; attempt < stampAttempts; attempt += 1) { if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2000)); const stamp = await execRemote( @@ -1330,6 +1477,24 @@ export async function verifyWindowsDeployment( : '') ); } + // 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) { + 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}. ` + + 'Revisa a qué binario apunta el arranque automático del servidor y los logs en ' + + `${prefix}\\config\\logs.` + ); + } await appendInstallStep( runId, 'verificar-version', @@ -1341,14 +1506,21 @@ export async function verifyWindowsDeployment( // 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 execRemote( - sftp, - psEncoded( - "$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + - 'if ($p) { "corriendo:" + $p.Count } else { "detenido" }' - ) - ); - const running = proc.stdout.trim().startsWith('corriendo'); + 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 @@ -1358,7 +1530,9 @@ export async function verifyWindowsDeployment( runId, 'verificar-proceso', running || autostart === 'desktop', - running ? 'CloudRestoreAS en ejecución' : 'CloudRestoreAS no está en ejecución' + running + ? `CloudRestoreAS en ejecución${proc.paths.length ? ` desde ${proc.paths.join(', ')}` : ''}` + : 'CloudRestoreAS no está en ejecución' ); return; } @@ -1391,7 +1565,8 @@ export async function verifyWindowsDeployment( runId, 'verificar-servicio', true, - `tarea CloudRestoreAS: ${state}, proceso en ejecución` + `tarea CloudRestoreAS: ${state}, proceso en ejecución` + + (proc.paths.length ? ` desde ${proc.paths.join(', ')}` : ' (ruta no legible)') ); } diff --git a/src/lib/server/cras-verify.ts b/src/lib/server/cras-verify.ts index d965249..6e095cc 100644 --- a/src/lib/server/cras-verify.ts +++ b/src/lib/server/cras-verify.ts @@ -20,6 +20,8 @@ import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg'; import { execRemote, probeLinuxElevation, + probeWindowsAgentProcess, + probeWindowsTask, psEncoded, readUnitProps, shQuote, @@ -821,19 +823,43 @@ async function inspectWindows( ) ); - // El proceso vivo es la señal más directa de que está trabajando. - const proc = await execRemote( - sftp, - psEncoded( - "$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + - 'if ($p) { "corriendo:" + $p.Count } else { "detenido" }' - ), - CHECK_TIMEOUT_MS - ); - const procOut = proc.stdout.trim(); - const running = procOut.startsWith('corriendo'); + // A qué ejecutable apunta el arranque automático. Es un chequeo aparte porque una tarea que + // apunta a otra carpeta hace que actualizar no surta efecto sin que nada falle: se reemplaza el + // binario de una y se arranca el de otra. Aquí se ve, en vez de descubrirse después. + const taskProbe = await probeWindowsTask(sftp); + const esperado = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`; + const taskAlineada = + !taskProbe.execute || taskProbe.execute.toLowerCase() === esperado.toLowerCase(); + if (taskProbe.exists) { + checks.push( + check( + 'ruta_arranque', + 'El arranque apunta a la instalación', + taskAlineada ? 'ok' : 'fail', + taskAlineada + ? taskProbe.execute || 'sin acción declarada' + : `la tarea ejecuta ${taskProbe.execute}, no ${esperado}` + ) + ); + } + + // El proceso vivo es la señal más directa de que está trabajando — pero tiene que ser el de + // ESTA instalación. Se comparte la sonda con el instalador para que las dos pantallas no puedan + // contradecirse sobre el mismo servidor. + const proc = await probeWindowsAgentProcess(sftp, prefix); + const running = proc.running; + const detalleProc = !proc.running + ? 'detenido' + : proc.paths.length === 0 + ? 'corriendo (ruta no legible)' + : `corriendo desde ${proc.paths.join(', ')}`; checks.push( - check('proceso', 'Proceso en ejecución', running ? 'ok' : 'warn', procOut || 'desconocido') + check( + 'proceso', + 'Proceso en ejecución', + !proc.running ? 'warn' : proc.paths.length && !proc.fromPrefix ? 'fail' : 'ok', + detalleProc + ) ); if (!installed) {