diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index 9a1f17c..e787c8b 100644 --- a/src/lib/server/cras-install.test.ts +++ b/src/lib/server/cras-install.test.ts @@ -29,7 +29,9 @@ import { probeInPlaceUpdate, probeLinuxElevation, alignWindowsTask, + checkWindowsArtifactUpdateSupport, normalizeWindowsPath, + probeInstallerUpdateSupport, probeWindowsAgentProcess, probeWindowsElevation, probeWindowsTask, @@ -471,6 +473,124 @@ describe('comparación de rutas de Windows', () => { }); }); +/** + * Artefactos anteriores a los arreglos de actualización. + * + * El caso real: se publicó un 1.1.3 construido antes de los arreglos, y el panel lo desplegó. Ese + * instalador mata el agente, cambia la tarea a SYSTEM y no comprueba que vuelva; y su binario ignora + * `--headless` en Windows, así que como SYSTEM en la sesión 0 no levanta. El servidor se quedó sin + * agente viejo y sin agente nuevo. + */ +describe('checkWindowsArtifactUpdateSupport', () => { + const SI = { answer: 'si', detail: '' } as const; + const NO = { answer: 'no', detail: '' } as const; + const NOSE = { answer: 'desconocido', detail: 'Get-Content: acceso denegado' } as const; + + it('ACTUALIZAR con un instalador antiguo se rechaza en vez de romper el servidor', () => { + try { + checkWindowsArtifactUpdateSupport('update', NO, '1.1.3', 'Principal'); + throw new Error('se esperaba que lanzara'); + } catch (e) { + const err = e as InstallError; + expect(err).toBeInstanceOf(InstallError); + // 409 y no 502: es un conflicto de estado —este artefacto no sirve para esto—, no una + // avería del servidor destino. + expect(err.status).toBe(409); + expect(err.message).toContain('1.1.3'); + expect(err.message).toContain('Principal'); + // El mensaje tiene que decir la CONSECUENCIA, no solo que falta un parámetro. + expect(err.message).toMatch(/SIN agente/); + } + }); + + it('INSTALAR limpio con el mismo artefacto solo avisa', () => { + // Ahí no hay agente en marcha que perder, así que negarse solo estorbaría. + const aviso = checkWindowsArtifactUpdateSupport('install', NO, '1.1.3', 'Principal'); + expect(aviso).toContain('1.1.3'); + expect(aviso).toMatch(/Se instala igual/); + }); + + it('un artefacto con los arreglos no dice nada, en ninguno de los dos modos', () => { + expect(checkWindowsArtifactUpdateSupport('update', SI, '1.1.4', 'Principal')).toBe(''); + expect(checkWindowsArtifactUpdateSupport('install', SI, '1.1.4', 'Principal')).toBe(''); + }); + + it('NO SABER no bloquea: es la prueba que faltaba y la que dejó 1.1.4 sin poder instalarse', () => { + // La sonda anterior colapsaba cualquier fallo en "no lo soporta", y sobre esa lectura se + // decidía un 409. Con un artefacto correcto, eso era un candado permanente. + const aviso = checkWindowsArtifactUpdateSupport('update', NOSE, '1.1.4', 'Principal'); + expect(aviso).toMatch(/No se pudo determinar/); + // Y dice POR QUÉ no se pudo, que es lo que evita adivinar la próxima vez. + expect(aviso).toContain('acceso denegado'); + }); + + it('tampoco bloquea al instalar cuando no se pudo determinar', () => { + expect(checkWindowsArtifactUpdateSupport('install', NOSE, '1.1.4', 'Principal')).toMatch( + /No se pudo determinar/ + ); + }); +}); + +/** + * La sonda que lee el instalador del artefacto. + * + * Lo que se protege aquí es la distinción entre "no lo declara" y "no pude leerlo". La versión + * anterior preguntaba por `(Get-Command …).Parameters`, funcionaba en una máquina de desarrollo y + * fallaba en el servidor real; como cualquier fallo se leía como "no", el panel acabó rechazando un + * artefacto correcto. + */ +describe('probeInstallerUpdateSupport', () => { + const RUTA = 'C:\\Temp\\cras\\CloudRestoreAS\\install.ps1'; + + it('lee el texto del script en vez de compilarlo', async () => { + const { sftp, commands } = fakeSftp(() => ({ code: 0, stdout: 'si' })); + const r = await probeInstallerUpdateSupport(sftp as never, RUTA); + expect(r.answer).toBe('si'); + + const script = Buffer.from( + commands[0].split('-EncodedCommand ')[1] ?? '', + 'base64' + ).toString('utf16le'); + // Nada de reflexión: en Constrained Language Mode el acceso a .Parameters puede fallar + // mientras `& install.ps1` sigue funcionando, que es lo que se observó en el servidor. + expect(script).not.toContain('Get-Command'); + expect(script).not.toContain('.Parameters'); + expect(script).toContain('Get-Content'); + // Anclado a la DECLARACIÓN, para que un comentario que nombre el parámetro no cuente. + expect(script).toContain('switch'); + }); + + it('un "no" limpio sigue siendo un no', async () => { + const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'no' })); + expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('no'); + }); + + it('código de salida distinto de cero es DESCONOCIDO, nunca "no"', async () => { + const { sftp } = fakeSftp(() => ({ code: 1, stdout: '', stderr: 'AmsiScanBuffer bloqueó' })); + const r = await probeInstallerUpdateSupport(sftp as never, RUTA); + expect(r.answer).toBe('desconocido'); + expect(r.detail).toContain('AmsiScanBuffer'); + }); + + it('stdout vacío con código 0 también es DESCONOCIDO', async () => { + // Es la forma exacta en que fallaba la sonda vieja: sin salida, y leído como respuesta. + const { sftp } = fakeSftp(() => ({ code: 0, stdout: ' ' })); + expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('desconocido'); + }); + + it('el archivo que no existe se distingue de un instalador antiguo', async () => { + const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'desconocido|no existe el archivo' })); + const r = await probeInstallerUpdateSupport(sftp as never, RUTA); + expect(r.answer).toBe('desconocido'); + expect(r.detail).toBe('no existe el archivo'); + }); + + it('una salida inesperada no se interpreta como respuesta', async () => { + const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'WARNING: algo raro' })); + expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('desconocido'); + }); +}); + describe('sondas de ruta en Windows', () => { const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS'; const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win'; @@ -499,12 +619,22 @@ describe('sondas de ruta en Windows', () => { 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({ + answered: true, exists: true, execute: 'C:\\Otra\\CloudRestoreAS.exe', principal: 'SYSTEM' }); }); + it('una sonda que no responde no significa "no hay tarea"', async () => { + // Con `answered: false` el llamador sabe que no se sabe. Antes, un comando fallido se leía + // como "no hay tarea registrada" y de ahí salían decisiones sobre elevación y realineación. + const { sftp } = fakeSftp(() => ({ code: 1, stdout: '' })); + const r = await probeWindowsTask(sftp as never); + expect(r.answered).toBe(false); + expect(r.exists).toBe(false); + }); + it('probeWindowsTask sin tarea registrada', async () => { const { sftp } = psFake('no||'); const r = await probeWindowsTask(sftp as never); @@ -646,11 +776,18 @@ describe('verifyWindowsDeployment', () => { /** Ruta que la tarea tiene registrada en su acción. */ taskExecute?: string; crashLog?: string; + /** De cuál de las rutas candidatas se leyó el crash log. */ + crashFrom?: string; }) { return fakeSftp((command) => { const encoded = command.split('-EncodedCommand ')[1] ?? ''; const script = Buffer.from(encoded, 'base64').toString('utf16le'); - if (script.includes('crash.log')) return { code: 0, stdout: opts.crashLog ?? '' }; + if (script.includes('crash.log')) { + // El lector responde `DE|` y luego el contenido, para poder decir de dónde salió. + if (!opts.crashLog) return { code: 0, stdout: '' }; + const de = opts.crashFrom ?? `${PREFIJO}\\CloudRestoreAS-crash.log`; + return { code: 0, stdout: `DE|${de}\n${opts.crashLog}` }; + } if (script.includes('config\\.version')) return { code: 0, stdout: opts.stamp }; if (script.includes('Get-Process')) { if (!opts.running) return { code: 0, stdout: 'detenido' }; @@ -752,6 +889,33 @@ describe('verifyWindowsDeployment', () => { expect(mensaje).toContain('ImportError'); }); + it('el crash log se encuentra también en el perfil de SYSTEM', async () => { + // Es el caso que importa: un agente lanzado por una tarea que corre como SYSTEM no escribe + // junto al ejecutable, sino bajo C:\Windows\System32\config\systemprofile. Mirar solo la + // primera ubicación dejaba el arranque fallido sin explicación. + const desde = + 'C:\\Windows\\System32\\config\\systemprofile\\AppData\\Local\\CloudRestoreAS\\crash.log'; + const { sftp } = fakeWindows({ + stamp: '', + taskState: 'Ready', + running: false, + crashLog: 'RuntimeError: could not load the Qt platform plugin "windows"', + crashFrom: desde + }); + const mensaje = await mensajeDeFallo( + sftp as never, + 1, + RELEASE, + 'service', + PREFIJO, + false, + true + ); + // Dice QUÉ pasó y DE DÓNDE lo sacó, para no tener que buscarlo a ciegas en el servidor. + expect(mensaje).toContain('Qt platform plugin'); + expect(mensaje).toContain(desde); + }); + it('sin crash log el mensaje no se rompe', async () => { const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: false }); const mensaje = await mensajeDeFallo( diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index 6b39608..5cfb342 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -1041,7 +1041,7 @@ async function verifyLinuxDeployment( // Windows // ============================================================================ -export type WindowsElevation = 'admin' | 'token-filtrado' | 'limitado'; +export type WindowsElevation = 'admin' | 'token-filtrado' | 'limitado' | 'desconocido'; export interface WindowsPrivilege { elevation: WindowsElevation; @@ -1075,7 +1075,22 @@ export async function probeWindowsElevation(sftp: SftpClient): Promise p.trim()).filter(Boolean); const wanted = windowsAgentExe(prefix); return { + answered: true, running: true, paths, fromPrefix: paths.some((p) => sameWindowsPath(p, wanted)) @@ -1187,6 +1214,8 @@ export async function probeWindowsAgentProcess( } 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; @@ -1221,14 +1250,138 @@ export async function probeWindowsTask(sftp: SftpClient): Promise { + const probe = await execRemote( + sftp, + psEncoded( + `$p = '${installerPath}'; ` + + "if (-not (Test-Path -LiteralPath $p)) { Write-Output 'desconocido|no existe el archivo'; exit 0 }; " + + 'try { ' + + '$t = Get-Content -LiteralPath $p -Raw -ErrorAction Stop; ' + + // El [regex]::IsMatch evita depender de $matches y del operador -match, que en + // modo restringido puede comportarse distinto. + 'if ([regex]::IsMatch($t, \'\\[switch\\]\\s*\\$UpdateInPlace\')) ' + + "{ 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: '' }; + 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 ''; + + // 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. * @@ -1341,22 +1494,50 @@ export async function restartWindowsAgent( } /** - * Cola del crash log que el agente escribe junto a su ejecutable cuando no consigue arrancar. + * Cola del crash log que el agente escribe cuando no consigue arrancar. * - * `runner.py` lo escribe precisamente para que un arranque fallido sea visible - * (`_crash_log_targets()`), y hasta ahora nadie lo leía nunca: el operador recibía "no arrancó" en - * lugar de "no arrancó porque X". Devuelve cadena vacía si no existe. + * 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 { +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( - `$p = '${prefix.replace(/\\+$/, '')}\\CloudRestoreAS-crash.log'; ` + - 'if (Test-Path -LiteralPath $p) { ' + - '(Get-Content -LiteralPath $p -Tail 20) -join [Environment]::NewLine }' + `$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 } }' ) ); - return result.stdout.trim(); + + 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( @@ -1531,27 +1712,21 @@ async function installWindows( // // 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í. + // NamedParameterNotFound SIN ejecutar una sola línea. const installerPs1 = `${remoteDir}\\CloudRestoreAS\\install.ps1`; - const supportsProbe = await execRemote( - sftp, - psEncoded( - `if ((Get-Command '${installerPs1}').Parameters.ContainsKey('UpdateInPlace')) ` + - "{'si'} else {'no'}" - ) + const soporte = await probeInstallerUpdateSupport(sftp, installerPs1); + + const avisoArtefacto = checkWindowsArtifactUpdateSupport( + request.mode, + soporte, + release.version, + target.name ); - 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' - ); + if (avisoArtefacto) { + await appendInstallStep(runId, 'instalador-antiguo', true, avisoArtefacto); } + + const inPlaceUpdate = request.mode === 'update' && soporte.answer === 'si'; const flag = inPlaceUpdate ? ' -UpdateInPlace' : autostart === 'none' @@ -1586,11 +1761,26 @@ async function installWindows( ); } + // 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, task.execute, targetPath)) { + if (await alignWindowsTask(sftp, runId, taskDespues.execute || task.execute, targetPath)) { const proc = await restartWindowsAgent(sftp, targetPath); await appendInstallStep( runId, @@ -1696,7 +1886,7 @@ export async function verifyWindowsDeployment( `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 ? ` Último crash del agente: ${truncate(crash)}` : '') + + (crash.text ? ` Último crash (${crash.from}): ${truncate(crash.text)}` : '') + ` Logs en ${prefix}\\config\\logs.` ); }