/** * 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. * - **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 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; 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. */ export function execRemote( sftp: SftpClient, command: string, timeoutMs = EXEC_TIMEOUT_MS ): 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; } 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í: ahí la falta de privilegios se aborta aquí, // 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); 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}` + (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}`); 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, `install.sh terminó con código ${install.code}: ${truncate(install.stderr || install.stdout)}` ); } // --- Verificación del despliegue ------------------------------------- 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. await cleanupStaging(sftp, runId, remoteDir, `rm -rf ${shQuote(remoteDir)}`); } } /** * Vías de elevación en Linux, de mejor a peor. * * `requiretty` se distingue de `ninguna` porque el remedio es el opuesto: con requiretty, * agregar NOPASSWD no sirve de nada —sudo rechaza antes de mirar la política— y el consejo de * "configura NOPASSWD" manda al operador a hacer algo inútil. Es un fallo de sudo, no de permisos. */ export type LinuxElevation = 'root' | 'sudo-sin-password' | 'requiretty' | 'ninguna'; export interface LinuxPrivilege { /** Prefijo a poner delante de los comandos que requieren root. Vacío si no hay elevación. */ prefix: string; label: string; elevation: LinuxElevation; /** Qué falta exactamente, en términos accionables. Vacío cuando sí hay elevación. */ detail: string; } /** * `Defaults requiretty` en el sudoers del destino (aún común en derivados de RHEL) hace fallar * cualquier sudo lanzado sobre un `exec` de SSH, que no tiene tty. El mensaje es estable desde * hace dos décadas, así que la firma de stderr es fiable — y se clasifica sobre la SONDA, que es * un comando controlado de una línea, no sobre la salida del instalador. */ function isRequireTty(stderr: string): boolean { return /must have a tty|no tty present/i.test(stderr); } /** * Sonda de elevación. La comparten el instalador y la pantalla de Verificar para que no puedan * contradecirse: antes cada uno tenía su propia copia y ya diferían en la etiqueta. * * Nunca se le pasa el password a sudo. La vía sin privilegios no es un error aquí: el modo de * instalación `user-service` no necesita ninguno, así que quien decide si falta algo es el * llamador, no esta función. */ export async function probeLinuxElevation(sftp: SftpClient): Promise { 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 -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, ' ')) : 'no es root y su sudo pide contraseña, que el panel nunca enví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` ); 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 // ============================================================================ 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 admin = await execRemote( sftp, psEncoded( '$p=[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent();' + 'if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){"admin"}else{"limitado"}' ) ); const autostart = request.autostart ?? 'service'; if (autostart === 'service' && admin.stdout.trim() !== '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.' ); } await appendInstallStep( runId, 'precondiciones', true, `${systemEvidence}, privilegios=${admin.stdout.trim()}` ); if (request.mode === 'update') { await assertExistingInstall(sftp, 'windows', installPath, 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 restringe el DIRECTORIO además del archivo: New-Item hereda el ACL del padre, y // si el $env:TEMP de la cuenta resuelve a C:\Windows\Temp ese padre es accesible por // todos. Y 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. const acl = await execRemote( sftp, psEncoded( `icacls '${remoteDir}' /inheritance:r ` + '/grant:r "BUILTIN\\Administrators:(F)" /grant:r "NT AUTHORITY\\SYSTEM:(F)" | Out-Null; ' + `icacls '${remoteEnv}' /inheritance:r ` + '/grant:r "BUILTIN\\Administrators:(F)" /grant:r "NT AUTHORITY\\SYSTEM:(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'}` ); } const flag = autostart === 'none' ? '' : autostart === 'service' ? ' -Service' : ' -Desktop'; await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`); const install = await execRemote( sftp, psEncoded( `& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` + `-Prefix '${installPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE` ), INSTALL_EXEC_TIMEOUT_MS ); if (install.code !== 0) { throw new InstallError( 502, `install.ps1 terminó con código ${install.code}: ${truncate(install.stderr || install.stdout)}` ); } await verifyWindowsDeployment(sftp, runId, release, autostart, installPath); } finally { await cleanupStaging( sftp, runId, remoteDir, psEncoded( `Remove-Item -LiteralPath '${remoteDir}' -Recurse -Force -ErrorAction Stop` ) ); } } async function verifyWindowsDeployment( sftp: SftpClient, runId: number, release: CrasRelease, autostart: AutostartMode, prefix: string ): 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(); if (deployed && deployed !== release.version) { throw new InstallError( 502, `El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}` ); } await appendInstallStep( runId, 'verificar-version', true, deployed ? `config\\.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)' ); if (autostart !== 'service') 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.' ); } await appendInstallStep(runId, 'verificar-servicio', true, `tarea CloudRestoreAS: ${state}`); } // ============================================================================ // 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; }