From eda355471d50b26f02d1f655ec35ae2a5551cd9a Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 30 Jul 2026 15:40:58 -0600 Subject: [PATCH] feat(cras-install): actualizar sin privilegios cuando no hay root ni sudo -n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En entornos donde no se usa root, ninguna de las dos vías que había servía: la elevación abortaba con 409 y `user-service` exige mover la instalación al home, que en un servidor con el agente ya instalado en /opt pide un paso privilegiado para retirar el unit viejo. Se agrega una tercera vía para ACTUALIZACIONES: dejar el unit como está y solo reemplazar el binario, que es lo único que una actualización necesita. Corre `install.sh --update-in-place` sin prefijo de elevación. Tres precondiciones, comprobadas por SSH ANTES de subir 270 MB, cada una con su propio motivo de rechazo porque cada una tiene un remedio distinto: - El directorio de instalación debe ser escribible por la cuenta SSH. Es el único permiso que hace falta: `install` desvincula el destino antes de crearlo, así que un binario en ejecución no es obstáculo (eso es cosa de `cp`). - El unit debe correr con ese mismo usuario. Si quedó con User=root —alguien instaló desde un `sudo -i`— la cuenta no puede señalizar el proceso. Un User vacío se trata como root, que es lo que hace systemd. - El unit debe tener Restart=always, que es quien vuelve a levantarlo. Sin eso, señalizarlo lo dejaría muerto. Y se rechaza si hay una restauración en curso, aquí y otra vez en el destino. Dos correcciones de robustez en la verificación posterior: - El sello config/.version se sondea en vez de leerse una vez. En esta vía se omite el bootstrap y el sello lo escribe el proceso al reiniciarse, así que durante unos segundos sigue teniendo la versión ANTERIOR: la lectura única convertía una actualización correcta en "reporta 1.0.0, se esperaba 1.1.1". - `systemctl is-active` puede devolver 'activating' justo tras el reinicio, así que la evidencia que manda es el proceso vivo con el binario nuevo. El código 75 (EX_TEMPFAIL) de install.sh se traduce a un 409 con el motivo real —"está restaurando, reintenta"— en vez del 502 genérico que hacía pensar que la instalación se había roto. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/server/cras-install.test.ts | 104 ++++++++++++++++ src/lib/server/cras-install.ts | 180 ++++++++++++++++++++++++---- 2 files changed, 263 insertions(+), 21 deletions(-) diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index de0271b..8cc9b07 100644 --- a/src/lib/server/cras-install.test.ts +++ b/src/lib/server/cras-install.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest'; import { execRemote, + probeInPlaceUpdate, probeLinuxElevation, psEncoded, shQuote, @@ -294,3 +295,106 @@ describe('probeLinuxElevation', () => { } }); }); + +/** + * Precondiciones de la actualización en sitio sin privilegios. + * + * Cada rechazo tiene un remedio distinto, y por eso no basta un booleano. Lo que se prueba aquí + * son los NO: el sí es trivial y el daño está en aceptar un servidor donde no se puede. + */ +describe('probeInPlaceUpdate', () => { + const PREFIX = '/opt/cloudrestoreas'; + + function sftpFor(responses: Record) { + return fakeSftp((command) => { + for (const [needle, result] of Object.entries(responses)) { + if (command.includes(needle)) return result; + } + return { code: 0, stdout: '' }; + }); + } + + /** Destino ideal: ruta del usuario, unit suyo con Restart=always, sin jobs. */ + const OK = { + 'test -w': { code: 0, stdout: 'si' }, + 'systemctl show': { code: 0, stdout: 'User=srvmid_db\nRestart=always' }, + '/Temp': { code: 0, stdout: '' } + }; + + it('acepta cuando se cumplen las tres condiciones', async () => { + const { sftp } = sftpFor(OK); + expect(await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db')).toMatchObject({ ok: true }); + }); + + it('rechaza si el directorio no es escribible, y dice de quién es', async () => { + // Sin escritura en el DIRECTORIO no se puede reemplazar el binario, y es el único + // permiso que hace falta: `install` desvincula antes de crear. + const { sftp } = sftpFor({ + ...OK, + 'test -w': { code: 1, stdout: 'no' }, + 'stat -c': { code: 0, stdout: 'root' } + }); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason).toContain('root'); + expect(r.reason).toContain(PREFIX); + }); + + it('rechaza si el servicio corre como otro usuario', async () => { + // Si el unit quedó con User=root —alguien instaló desde un `sudo -i`— la cuenta SSH no + // puede señalizar el proceso y toda la vía se cae. + const { sftp } = sftpFor({ + ...OK, + 'systemctl show': { code: 0, stdout: 'User=root\nRestart=always' } + }); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/corre como 'root'/); + }); + + it('trata el User vacío como root, que es lo que hace systemd', async () => { + const { sftp } = sftpFor({ + ...OK, + 'systemctl show': { code: 0, stdout: 'User=\nRestart=always' } + }); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/root/); + }); + + it('rechaza sin Restart=always: matarlo lo dejaría muerto', async () => { + for (const restart of ['on-failure', 'no', '']) { + const { sftp } = sftpFor({ + ...OK, + 'systemctl show': { code: 0, stdout: `User=srvmid_db\nRestart=${restart}` } + }); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok, `Restart=${restart}`).toBe(false); + expect(r.reason).toMatch(/Restart=always/); + } + }); + + it('rechaza si hay una restauración en curso, antes de subir 270 MB', async () => { + // El mismo chequeo lo repite install.sh en el destino, pero hacerlo aquí evita la + // transferencia completa para nada. + const { sftp } = sftpFor({ ...OK, '/Temp': { code: 0, stdout: 'job-4212' } }); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/restauración en curso/); + }); + + it('el motivo del rechazo nunca queda vacío: cada caso tiene su remedio', async () => { + const casos = [ + { ...OK, 'test -w': { code: 1, stdout: 'no' } }, + { ...OK, 'systemctl show': { code: 0, stdout: 'User=otro\nRestart=always' } }, + { ...OK, 'systemctl show': { code: 0, stdout: 'User=srvmid_db\nRestart=no' } }, + { ...OK, '/Temp': { code: 0, stdout: 'job-1' } } + ]; + for (const caso of casos) { + const { sftp } = sftpFor(caso); + const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); + expect(r.ok).toBe(false); + expect(r.reason.trim().length).toBeGreaterThan(20); + } + }); +}); diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index d180259..652510d 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -504,21 +504,37 @@ async function installLinux( // antes de transferir 270 MB, porque fallar después es desperdicio y deja basura en /tmp. const autostartMode = request.autostart ?? 'service'; const privileged = await probeLinuxElevation(sftp); - if (autostartMode !== 'user-service' && !privileged.prefix && privileged.elevation !== 'root') { - throw new InstallError( - 409, - `El usuario '${target.ssh_username}' no puede elevar privilegios en este servidor: ` + - `${privileged.detail} Para instalar en ${installPath} y registrar el servicio de ` + - 'sistema hacen falta. Alternativas: usar una cuenta root, dar NOPASSWD a ese ' + - 'usuario, o elegir el arranque "Servicio de usuario", que instala en el home y no ' + - 'necesita privilegios.' - ); + const canElevate = privileged.elevation === 'root' || privileged.elevation === 'sudo-sin-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. + 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}` + `${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. @@ -591,7 +607,13 @@ async function installLinux( } const autostart = request.autostart ?? 'service'; - const installerFlags = autostart === 'none' ? '' : ` --${autostart}`; + // 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 = @@ -601,6 +623,17 @@ async function installLinux( await appendInstallStep(runId, 'ejecutar-instalador', true, `install.sh${installerFlags}`); const install = await execRemote(sftp, installCmd, INSTALL_EXEC_TIMEOUT_MS); + // 75 = EX_TEMPFAIL, que install.sh reserva para "hay una restauración en curso". Es un + // conflicto de estado, no un fallo: hay que reintentar, no investigar. Con el 502 genérico + // parecía que la instalación se había roto, y el operador iría a buscar la causa equivocada. + 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, @@ -609,7 +642,9 @@ async function installLinux( } // --- Verificación del despliegue ------------------------------------- - await verifyLinuxDeployment(sftp, runId, release, privileged, autostart, installPath); + await verifyLinuxDeployment( + sftp, runId, release, privileged, autostart, installPath, inPlaceUpdate + ); } finally { // Limpieza siempre: el panel.env trae el token en claro. Si falla NO se silencia — // significa que el token se quedó en el servidor y alguien tiene que ir a borrarlo. @@ -696,6 +731,87 @@ export async function probeLinuxElevation(sftp: SftpClient): Promise { + 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.` + }; + } + + // `systemctl show` se lee sin privilegios, a diferencia de start/stop. + const unit = await execRemote( + sftp, + 'systemctl show -p User -p Restart cloudrestoreas 2>/dev/null' + ); + const props = new Map( + unit.stdout + .split('\n') + .map((line) => line.trim().split('=')) + .filter((parts) => parts.length === 2) + .map(([k, v]) => [k, v] as const) + ); + // User vacío en systemd significa root: el unit no declaró User=. + const unitUser = props.get('User') || 'root'; + 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, @@ -705,19 +821,36 @@ async function verifyLinuxDeployment( // 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 + 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). - const stamp = await execRemote( - sftp, - `cat ${shQuote(`${installPath}/config/.version`)} 2>/dev/null` - ); - const deployed = stamp.stdout.trim(); + // + // 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}` + `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( @@ -733,6 +866,9 @@ async function verifyLinuxDeployment( // 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, @@ -745,7 +881,7 @@ async function verifyLinuxDeployment( // 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) { + 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ó. @@ -758,7 +894,9 @@ async function verifyLinuxDeployment( runId, 'verificar-servicio', true, - 'proceso vivo (arranque por cron; systemd de usuario no disponible)' + inPlaceUpdate + ? `proceso vivo con el binario nuevo (unit intacto, estado: ${state})` + : 'proceso vivo (arranque por cron; systemd de usuario no disponible)' ); return; }