diff --git a/src/lib/server/api-error.ts b/src/lib/server/api-error.ts index d182183..e8fc7b6 100644 --- a/src/lib/server/api-error.ts +++ b/src/lib/server/api-error.ts @@ -9,11 +9,20 @@ export function newTraceId(): string { return randomUUID(); } +/** + * Estados que el panel emite. Es una lista cerrada a propósito (Aduanasoft §5): si un error no + * encaja en ninguno, casi siempre es que está mal modelado, no que falte un código. + * + * Se exporta para que quien construya errores propios —como `AgentControlError`— tipe su estado + * con esto y el compilador lo valide donde se crea, en vez de descubrirlo al serializarlo. + */ +export type ApiErrorStatus = 400 | 401 | 403 | 404 | 409 | 422 | 500 | 502 | 503; + export function errorJson( // 502 para fallos de un servicio del que dependemos (p.ej. Gitea al sincronizar // versiones de CRAS): distinguirlo de 503 le dice al operador que el panel está bien y // el problema está aguas arriba. - code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 502 | 503, + code: ApiErrorStatus, message: string, traceId: string ) { diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index 8cc9b07..5eb79e8 100644 --- a/src/lib/server/cras-install.test.ts +++ b/src/lib/server/cras-install.test.ts @@ -1,36 +1,69 @@ /** * Instalador remoto de CloudRestoreAS: utilidades de ejecución y quoting. * - * La prueba más importante de este archivo es la de fuga de secretos: el token del panel - * NUNCA debe aparecer en un comando remoto, porque `ps` y el historial del servidor destino - * son legibles por otros usuarios. Es exactamente el defecto del instalador de AServers, que - * hace `echo '{password}' | sudo -S ...`. + * La prueba más importante de este archivo es la de fuga de secretos: ni el token del panel ni + * la contraseña SSH deben aparecer NUNCA en un comando remoto, porque `ps` y el historial del + * servidor destino son legibles por otros usuarios. Es exactamente el defecto del instalador de + * AServers, que hace `echo '{password}' | sudo -S ...`. + * + * Nótese qué prohíbe ese invariante y qué no: prohíbe el secreto en la LÍNEA DE COMANDOS, no + * elevar con contraseña. `sudo -S` alimentado por el stdin del canal cumple la regla, y es lo + * que permite instalar en /opt sin ir a tocar el sudoers del servidor. */ import { describe, expect, it, vi } from 'vitest'; + +// La verificación del despliegue asienta cada paso en cras_install_runs, que va a PostgreSQL. +// Aquí interesa la DECISIÓN (¿esto cuenta como desplegado?), no la bitácora, así que se +// sustituye el módulo entero en vez de levantar una base. +vi.mock('./cras-releases', () => ({ + appendInstallStep: vi.fn(async () => {}), + finishInstallRun: vi.fn(async () => {}), + getCrasReleaseById: vi.fn(async () => null), + listCrasTargetInventory: vi.fn(async () => []), + startInstallRun: vi.fn(async () => 1), + InstallRunConflictError: class extends Error {} +})); + import { execRemote, probeInPlaceUpdate, probeLinuxElevation, + probeWindowsElevation, psEncoded, shQuote, + verifyWindowsDeployment, InstallError } from './cras-install'; /** Cliente SFTP falso que expone un `client.exec` controlable, como el real. */ function fakeSftp(handler: (command: string) => { code?: number; stdout?: string; stderr?: string }) { const commands: string[] = []; + /** Lo que se escribió en el stdin de cada comando, en el mismo orden que `commands`. */ + const stdins: (string | null)[] = []; const sftp = { client: { exec(command: string, callback: (err: Error | null, stream?: unknown) => void) { commands.push(command); + const index = stdins.push(null) - 1; const result = handler(command); const listeners: Record void)[]> = {}; const stderrListeners: ((...args: unknown[]) => void)[] = []; + let ended = false; const stream = { on(event: string, fn: (...args: unknown[]) => void) { (listeners[event] ??= []).push(fn); return stream; }, + write(chunk: string) { + stdins[index] = (stdins[index] ?? '') + chunk; + return true; + }, + end() { + ended = true; + }, + get stdinClosed() { + return ended; + }, stderr: { on(_event: string, fn: (...args: unknown[]) => void) { stderrListeners.push(fn); @@ -53,7 +86,7 @@ function fakeSftp(handler: (command: string) => { code?: number; stdout?: string } } }; - return { sftp, commands }; + return { sftp, commands, stdins }; } describe('shQuote', () => { @@ -277,22 +310,170 @@ describe('probeLinuxElevation', () => { const r = await probeLinuxElevation(sftp as never); expect(r.elevation).toBe('ninguna'); expect(r.prefix).toBe(''); - expect(r.detail).toMatch(/contraseña, que el panel nunca envía/); }); - it('NUNCA construye un prefijo que le pida la contraseña a sudo', async () => { - // La política del módulo: ni por argv ni por stdin. El modo sin privilegios existe - // precisamente para no tener que romperla. - for (const stderr of ['sudo: a password is required', 'sudo: sorry, you must have a tty']) { - const { sftp } = sftpFor({ - 'id -u': { code: 0, stdout: '1000' }, - 'sudo -n true': { code: 1, stderr }, - 'sudo -n -l': { code: 1 } - }); - const r = await probeLinuxElevation(sftp as never); - expect(r.prefix).not.toMatch(/-S/); - expect(r.prefix).not.toMatch(/echo/); - } + it('sudo con la contraseña del panel: eleva cuando `sudo -n` no puede', async () => { + // El caso que dejaba tirado al operador: la cuenta SÍ está en el sudoers, solo que con + // contraseña. El panel ya la tiene —es con la que abrió la sesión— y ahora la usa. + const { sftp } = sftpFor({ + 'id -u': { code: 0, stdout: '1000' }, + 'sudo -n true': { code: 1, stderr: 'sudo: a password is required' }, + "sudo -S -k -p '' true": { code: 0 } + }); + const r = await probeLinuxElevation(sftp as never, 'secreto-del-panel'); + expect(r.elevation).toBe('sudo-con-password'); + expect(r.prefix).toContain('-S'); + // El prefijo y su stdin son inseparables: sin esto sudo se queda esperando. + expect(r.stdin).toBe('secreto-del-panel\n'); + }); + + it('la contraseña viaja por stdin y NUNCA por la línea de comandos', async () => { + // El invariante que hereda del defecto de AServers: `echo '{pw}' | sudo -S` deja la + // contraseña en el argv del `sh -c`, legible con `ps` por cualquier usuario del destino. + // Lo que se prohíbe es eso, no elevar con contraseña. + const PASSWORD = 'clave-que-no-debe-filtrarse'; + const { sftp, commands, stdins } = sftpFor({ + 'id -u': { code: 0, stdout: '1000' }, + 'sudo -n true': { code: 1, stderr: 'sudo: a password is required' }, + "sudo -S -k -p '' true": { code: 0 } + }); + const r = await probeLinuxElevation(sftp as never, PASSWORD); + + expect(commands.some((c) => c.includes(PASSWORD))).toBe(false); + expect(commands.some((c) => c.includes('echo'))).toBe(false); + expect(r.prefix).not.toContain(PASSWORD); + // Y sí llegó por el único canal admitido. + expect(stdins.some((s) => s?.includes(PASSWORD))).toBe(true); + }); + + it('requiretty NO gasta un intento de contraseña: sudo rechaza antes de mirar la política', async () => { + // Mandarle la contraseña ahí no arreglaría nada y sumaría un fallo al log de auth del + // destino, que es justo lo que dispara las alertas de fuerza bruta. + const { sftp, commands } = sftpFor({ + 'id -u': { code: 0, stdout: '1000' }, + 'sudo -n true': { code: 1, stderr: 'sudo: sorry, you must have a tty to run sudo' } + }); + const r = await probeLinuxElevation(sftp as never, 'secreto-del-panel'); + expect(r.elevation).toBe('requiretty'); + expect(commands.some((c) => c.includes('-S'))).toBe(false); + }); + + it('contraseña rechazada: lo dice, en vez de culpar a la falta de sudo', async () => { + // Los dos remedios son opuestos: meter al usuario en el sudoers, o corregir la + // contraseña registrada en el panel. Un mensaje genérico manda a hacer lo que no es. + const { sftp } = sftpFor({ + 'id -u': { code: 0, stdout: '1000' }, + 'sudo -n true': { code: 1, stderr: 'sudo: a password is required' }, + "sudo -S -k -p '' true": { code: 1, stderr: 'sudo: 1 incorrect password attempt' }, + 'sudo -n -l': { code: 1, stdout: '' } + }); + const r = await probeLinuxElevation(sftp as never, 'clave-vieja'); + expect(r.elevation).toBe('ninguna'); + expect(r.prefix).toBe(''); + expect(r.detail).toMatch(/contraseña SSH guardada en el panel/); + }); +}); + +/** + * Sonda de elevación en Windows. + * + * El estado que se añadió —y el motivo de estas pruebas— es "administrador con token filtrado": + * una cuenta que SÍ está en Administradores pero a la que OpenSSH entregó el token filtrado por + * UAC. Se veía igual que "no eres administrador", y el remedio es el opuesto: ahí la cuenta ya + * es la correcta y lo que falta es una política del servidor. + */ +describe('probeWindowsElevation', () => { + /** El comando va base64/UTF-16LE dentro de -EncodedCommand: hay que decodificarlo. */ + function fakePsSftp(reply: string) { + return fakeSftp((command) => { + const encoded = command.split('-EncodedCommand ')[1] ?? ''; + const script = Buffer.from(encoded, 'base64').toString('utf16le'); + // Se comprueba que la sonda mire el SID del grupo, que es lo único que distingue el + // token filtrado de una cuenta sin privilegios. + if (!script.includes('S-1-5-32-544')) return { code: 1, stdout: '' }; + return { code: 0, stdout: reply }; + }); + } + + it('administrador con token completo', async () => { + const { sftp } = fakePsSftp('admin'); + const r = await probeWindowsElevation(sftp as never); + expect(r.elevation).toBe('admin'); + expect(r.detail).toBe(''); + }); + + it('token filtrado por UAC: NO manda a cambiar de cuenta', async () => { + const { sftp } = fakePsSftp('token-filtrado'); + const r = await probeWindowsElevation(sftp as never); + expect(r.elevation).toBe('token-filtrado'); + // El remedio correcto es la política del servidor, no otra cuenta. + expect(r.detail).toMatch(/LocalAccountTokenFilterPolicy/); + expect(r.detail).toMatch(/No hay que cambiar de cuenta/); + }); + + it('cuenta sin privilegios: ahí sí hace falta otra cuenta', async () => { + const { sftp } = fakePsSftp('limitado'); + const r = await probeWindowsElevation(sftp as never); + expect(r.elevation).toBe('limitado'); + expect(r.detail).toMatch(/cuenta administradora/); + }); +}); + +/** + * 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. + */ +describe('verifyWindowsDeployment', () => { + const RELEASE = { version: '1.1.2' } as never; + + /** Responde a cada script de PowerShell según lo que pregunte. */ + function fakeWindows(opts: { stamp: string; taskState: string; running: boolean }) { + 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 (script.includes('Get-ScheduledTask')) return { code: 0, stdout: opts.taskState }; + return { code: 1, stdout: '' }; + }); + } + + it('tarea Ready con el proceso muerto NO cuenta como desplegado', async () => { + const { sftp } = fakeWindows({ stamp: '1.1.2', taskState: 'Ready', running: false }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') + ).rejects.toThrow(/el proceso no está corriendo/); + }); + + it('tarea corriendo y proceso vivo sí cuenta', async () => { + const { sftp } = fakeWindows({ stamp: '1.1.2', taskState: 'Running', running: true }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') + ).resolves.toBeUndefined(); + }); + + it('el proceso se comprueba también fuera del modo servicio', async () => { + // Antes se salía antes de mirar nada cuando el arranque no era 'service', así que + // actualizar con 'none' dejaba el agente muerto y el run quedaba en verde. + const { sftp, commands } = fakeWindows({ stamp: '1.1.2', taskState: '', running: false }); + await verifyWindowsDeployment(sftp as never, 1, RELEASE, 'none', 'C:\\CRAS'); + const scripts = commands.map((c) => + Buffer.from(c.split('-EncodedCommand ')[1] ?? '', 'base64').toString('utf16le') + ); + expect(scripts.some((s) => s.includes('Get-Process'))).toBe(true); + }); + + it('un sello con otra versión es un fallo, no un aviso', async () => { + const { sftp } = fakeWindows({ stamp: '1.1.1', taskState: 'Running', running: true }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') + ).rejects.toThrow(/reporta la versión 1\.1\.1/); }); }); diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index f1933fb..e61f4d5 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -12,11 +12,20 @@ * 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. - * - **Nunca se le pasa el password a sudo**, ni por argv ni por stdin. Para los modos que - * instalan en /opt y registran un servicio de sistema se exige root o `sudo -n` (sudo sin - * password), y si no hay ninguno se aborta ANTES de subir 270 MB. El modo `user-service` - * evita el problema en vez de rodearlo: instala bajo el home con un unit de systemd de - * usuario, así que no necesita elevación alguna y le bastan las credenciales SSH. + * - **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 @@ -54,6 +63,13 @@ 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, @@ -76,11 +92,19 @@ export interface ExecResult { * `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 + timeoutMs = EXEC_TIMEOUT_MS, + stdin?: string ): Promise { const conn = (sftp as unknown as { client: SshClient }).client; if (!conn || typeof conn.exec !== 'function') { @@ -103,6 +127,12 @@ export function execRemote( 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; @@ -500,15 +530,19 @@ async function installLinux( 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í: ahí la falta de privilegios se aborta aquí, - // antes de transferir 270 MB, porque fallar después es desperdicio y deja basura en /tmp. + // 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); - const canElevate = privileged.elevation === 'root' || privileged.elevation === 'sudo-sin-password'; + const privileged = await probeLinuxElevation(sftp, target.ssh_password); + const canElevate = + privileged.elevation === 'root' || + privileged.elevation === 'sudo-sin-password' || + privileged.elevation === 'sudo-con-password'; - // Sin elevación quedan dos vías antes de rendirse, y ambas evitan pedirle la contraseña a - // sudo. `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. + // 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') { @@ -621,8 +655,21 @@ async function installLinux( `${privileged.prefix}env PREFIX=${shQuote(installPath)} ` + `./install.sh${installerFlags} --panel-env-file ${shQuote(remoteEnv)}`; - await appendInstallStep(runId, 'ejecutar-instalador', true, `install.sh${installerFlags}`); - const install = await execRemote(sftp, installCmd, INSTALL_EXEC_TIMEOUT_MS); + 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 ` { +export async function probeLinuxElevation( + sftp: SftpClient, + sshPassword?: string +): Promise { const id = await execRemote(sftp, 'id -u'); if (id.code === 0 && id.stdout.trim() === '0') { return { prefix: '', label: 'root', elevation: 'root', detail: '' }; @@ -715,6 +783,32 @@ export async function probeLinuxElevation(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"}' + ) + ); + + switch (probe.stdout.trim()) { + 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.' + }; + } +} + async function installWindows( sftp: SftpClient, runId: number, @@ -954,26 +1113,35 @@ async function installWindows( // --- Precondiciones ------------------------------------------------------ const systemEvidence = await assertSystemMatches(sftp, 'windows'); - const admin = await execRemote( + 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( - '$p=[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent();' + - 'if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){"admin"}else{"limitado"}' + "if (Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue) " + + '{"si"} else {"no"}' ) ); - const autostart = request.autostart ?? 'service'; - if (autostart === 'service' && admin.stdout.trim() !== 'admin') { + const taskExists = taskProbe.stdout.trim() === 'si'; + const needsElevation = autostart === 'service' || taskExists; + + if (needsElevation && privileged.elevation !== 'admin') { throw new InstallError( 409, - 'El usuario SSH no es Administrador en el destino. La tarea programada ONSTART corre ' + - 'como SYSTEM y requiere elevación; usa una cuenta administradora o instala sin servicio.' + `No se puede operar la tarea programada de ${target.name}: ${privileged.detail}` ); } await appendInstallStep( runId, 'precondiciones', true, - `${systemEvidence}, privilegios=${admin.stdout.trim()}` + `${systemEvidence}, privilegios=${privileged.label}` + + (taskExists ? ', tarea CloudRestoreAS ya registrada' : '') ); if (request.mode === 'update') { @@ -1067,7 +1235,18 @@ async function installWindows( ); } - const flag = autostart === 'none' ? '' : autostart === 'service' ? ' -Service' : ' -Desktop'; + // 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'; + const flag = inPlaceUpdate + ? ' -UpdateInPlace' + : autostart === 'none' + ? '' + : autostart === 'service' + ? ' -Service' + : ' -Desktop'; await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`); const install = await execRemote( sftp, @@ -1077,6 +1256,17 @@ async function installWindows( ), INSTALL_EXEC_TIMEOUT_MS ); + // 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, @@ -1084,7 +1274,9 @@ async function installWindows( ); } - await verifyWindowsDeployment(sftp, runId, release, autostart, installPath); + await verifyWindowsDeployment( + sftp, runId, release, autostart, installPath, inPlaceUpdate + ); } finally { await cleanupStaging( sftp, @@ -1097,25 +1289,45 @@ async function installWindows( } } -async function verifyWindowsDeployment( +export async function verifyWindowsDeployment( sftp: SftpClient, runId: number, release: CrasRelease, autostart: AutostartMode, - prefix: string + prefix: string, + inPlaceUpdate = false ): Promise { - const stamp = await execRemote( - sftp, - psEncoded( - `if (Test-Path '${prefix}\\config\\.version') ` + - `{ Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw }` - ) - ); - const deployed = stamp.stdout.trim(); + // 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 = ''; + const stampAttempts = inPlaceUpdate ? 15 : 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}` + `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( @@ -1125,7 +1337,31 @@ async function verifyWindowsDeployment( deployed ? `config\\.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)' ); - if (autostart !== 'service') return; + // 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 execRemote( + sftp, + psEncoded( + "$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + + 'if ($p) { "corriendo:" + $p.Count } else { "detenido" }' + ) + ); + const running = proc.stdout.trim().startsWith('corriendo'); + + 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' : 'CloudRestoreAS no está en ejecución' + ); + return; + } const task = await execRemote( sftp, @@ -1140,7 +1376,23 @@ async function verifyWindowsDeployment( 'La tarea programada CloudRestoreAS no quedó registrada en el destino.' ); } - await appendInstallStep(runId, 'verificar-servicio', true, `tarea CloudRestoreAS: ${state}`); + // `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` + ); } // ============================================================================