Reviewed-on: #25 Co-authored-by: hreyes <hreyes@aduanasoft.com.mx> Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
1832 lines
81 KiB
TypeScript
1832 lines
81 KiB
TypeScript
/**
|
|
* 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.
|
|
* - **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
|
|
* 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;
|
|
|
|
/**
|
|
* 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,
|
|
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.
|
|
*
|
|
* `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,
|
|
stdin?: string
|
|
): Promise<ExecResult> {
|
|
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<ExecResult>((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;
|
|
}
|
|
// 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;
|
|
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<InstallOutcome> {
|
|
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<void> {
|
|
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<string> {
|
|
// 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<void> {
|
|
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<void> {
|
|
// --- 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í, 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, target.ssh_password);
|
|
const canElevate =
|
|
privileged.elevation === 'root' ||
|
|
privileged.elevation === 'sudo-sin-password' ||
|
|
privileged.elevation === 'sudo-con-password';
|
|
|
|
// 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') {
|
|
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} (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 `</dev/null`—, así que no hay quién se coma la línea de más.
|
|
const install = await execRemote(
|
|
sftp,
|
|
installCmd,
|
|
INSTALL_EXEC_TIMEOUT_MS,
|
|
privileged.stdin
|
|
);
|
|
// 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'
|
|
| 'sudo-con-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;
|
|
/**
|
|
* Lo que hay que escribir en el stdin del comando cuando se usa `prefix`. Solo lo llena
|
|
* `sudo-con-password`, y es la contraseña SSH del destino.
|
|
*
|
|
* Viaja junto al prefijo, y no como algo que cada llamador recuerde por su cuenta, porque
|
|
* son inseparables: usar el prefijo sin alimentar el stdin deja a sudo esperando una
|
|
* contraseña que no llega hasta agotar el timeout del comando.
|
|
*/
|
|
stdin?: 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.
|
|
*
|
|
* `sshPassword` es la credencial que el panel ya usó para abrir esta sesión. Se prueba como
|
|
* último recurso de elevación, y solo por el stdin del canal (ver `execRemote`). Omitirla
|
|
* degrada la sonda a las vías sin secreto, que es lo que quiere la pantalla de Verificar: ahí
|
|
* interesa el estado real del servidor, no elevar nada.
|
|
*
|
|
* 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,
|
|
sshPassword?: string
|
|
): Promise<LinuxPrivilege> {
|
|
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:<usuario> !requiretty`.'
|
|
};
|
|
}
|
|
|
|
// Sudo pide contraseña, y el panel tiene una: la misma con la que abrió esta sesión SSH.
|
|
// Se prueba DESPUÉS de `sudo -n` (que no gasta ningún intento) y DESPUÉS de descartar
|
|
// requiretty (donde sudo rechaza antes de mirar la política, así que la contraseña no
|
|
// arreglaría nada y solo sumaría un fallo al log de auth del destino).
|
|
//
|
|
// `-k` invalida el sello de sudo antes de correr: sin él, una sesión reciente haría pasar la
|
|
// sonda sin haber comprobado nada, y el comando real fallaría más tarde ya con 270 MB
|
|
// subidos. `-p ''` deja el prompt vacío para que no se cuele en el stderr que se asienta.
|
|
if (sshPassword) {
|
|
const withPassword = await execRemote(
|
|
sftp,
|
|
`${SUDO_PASSWORD_PREFIX}true`,
|
|
EXEC_TIMEOUT_MS,
|
|
`${sshPassword}\n`
|
|
);
|
|
if (withPassword.code === 0) {
|
|
return {
|
|
prefix: SUDO_PASSWORD_PREFIX,
|
|
stdin: `${sshPassword}\n`,
|
|
label: 'sudo con la contraseña del panel',
|
|
elevation: 'sudo-con-password',
|
|
detail: ''
|
|
};
|
|
}
|
|
}
|
|
|
|
// `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, ' '))
|
|
: sshPassword
|
|
? 'no es root, y su sudo tampoco aceptó la contraseña SSH guardada en el panel: ' +
|
|
'o el usuario no está en el sudoers, o la contraseña registrada ya no es la ' +
|
|
'vigente en el servidor.'
|
|
: 'no es root y su sudo pide contraseñ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<Map<string, string>> {
|
|
const flags = props.map((name) => `-p ${name}`).join(' ');
|
|
const out = await execRemote(sftp, `systemctl show ${flags} ${unitName} 2>/dev/null`);
|
|
const parsed = new Map<string, string>();
|
|
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, string>): 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<void> {
|
|
// 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`,
|
|
EXEC_TIMEOUT_MS,
|
|
userMode ? undefined : privileged.stdin
|
|
);
|
|
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
|
|
// ============================================================================
|
|
|
|
export type WindowsElevation = 'admin' | 'token-filtrado' | 'limitado';
|
|
|
|
export interface WindowsPrivilege {
|
|
elevation: WindowsElevation;
|
|
label: string;
|
|
/** Qué falta exactamente, en términos accionables. Vacío cuando sí hay elevación. */
|
|
detail: string;
|
|
}
|
|
|
|
/**
|
|
* Sonda de elevación en Windows.
|
|
*
|
|
* Distingue tres estados donde antes había dos, y la distinción que se añade es la que importa:
|
|
* una cuenta que ESTÁ en Administradores puede recibir de OpenSSH el token **filtrado** por UAC,
|
|
* y entonces `IsInRole(Administrator)` responde que no. El panel reportaba eso como "el usuario
|
|
* SSH no es Administrador" y mandaba a cambiar de cuenta — pero la cuenta ya era la correcta, y
|
|
* cambiarla no arregla nada. Lo que falta ahí es una política del servidor.
|
|
*
|
|
* La pertenencia al grupo se lee del token (SID S-1-5-32-544). En un token filtrado el grupo
|
|
* sigue presente, marcado como deny-only, que es justo lo que permite separar los dos casos.
|
|
*/
|
|
export async function probeWindowsElevation(sftp: SftpClient): Promise<WindowsPrivilege> {
|
|
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.'
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Comparación de rutas de Windows. Una sola implementación, a propósito.
|
|
*
|
|
* La pregunta "¿son la misma ruta?" estaba resuelta en tres sitios con tres criterios ligeramente
|
|
* distintos (una recortaba comillas múltiples, otra una sola, la tercera ninguna). Coincidían en los
|
|
* casos reales, pero este módulo ya lleva escrito lo que cuesta esa duplicación: `probeLinuxElevation`
|
|
* e `inspectLinux` tenían copias paralelas y las dos pantallas acabaron diciendo cosas distintas del
|
|
* mismo servidor.
|
|
*
|
|
* Lo que hay que normalizar y por qué:
|
|
* - **Comillas**: la acción de una tarea programada guarda la ruta entrecomillada.
|
|
* - **Barras finales**: una ruta capturada a mano suele traerlas.
|
|
* - **Mayúsculas**: NTFS no las distingue, así que comparar sensible al caso reporta dos rutas
|
|
* distintas donde el sistema de archivos ve una sola.
|
|
*
|
|
* Lo que NO se hace es comparar por prefijo. `C:\Aduanasoft\CloudRestoreAS` es prefijo de cadena de
|
|
* `C:\Aduanasoft\CloudRestoreAS-win`, así que un `startsWith` daría por iguales dos instalaciones
|
|
* distintas — y esa combinación existe en producción.
|
|
*/
|
|
export function normalizeWindowsPath(value: string): string {
|
|
return String(value ?? '')
|
|
.trim()
|
|
.replace(/^"+|"+$/g, '')
|
|
.replace(/\\+$/, '')
|
|
.toLowerCase();
|
|
}
|
|
|
|
/** ¿Las dos rutas apuntan al mismo archivo o carpeta? Igualdad exacta tras normalizar. */
|
|
export function sameWindowsPath(a: string, b: string): boolean {
|
|
const na = normalizeWindowsPath(a);
|
|
const nb = normalizeWindowsPath(b);
|
|
return na !== '' && na === nb;
|
|
}
|
|
|
|
/** Ruta del ejecutable del agente dentro de una carpeta de instalación. */
|
|
export function windowsAgentExe(prefix: string): string {
|
|
return `${String(prefix ?? '').trim().replace(/\\+$/, '')}\\CloudRestoreAS.exe`;
|
|
}
|
|
|
|
export interface WindowsProcessProbe {
|
|
running: boolean;
|
|
/** Rutas de los ejecutables vivos. Vacío si corren pero no se pudo leer su ruta. */
|
|
paths: string[];
|
|
/** Alguno corre el binario del prefijo indicado. */
|
|
fromPrefix: boolean;
|
|
}
|
|
|
|
/**
|
|
* Procesos del agente en el destino, con la RUTA de su ejecutable.
|
|
*
|
|
* El nombre no distingue instalaciones: `Get-Process -Name CloudRestoreAS` responde igual para el
|
|
* agente que acabamos de poner y para uno viejo que sigue vivo en otra carpeta.
|
|
*
|
|
* Si la ruta no es legible se devuelve `paths` vacío y `fromPrefix` en false, y el llamador NO debe
|
|
* concluir que es de otra instalación: un proceso de SYSTEM no expone su ruta a una cuenta sin
|
|
* elevación, y tratar eso como fallo rompería actualizaciones correctas.
|
|
*/
|
|
export async function probeWindowsAgentProcess(
|
|
sftp: SftpClient,
|
|
prefix: string
|
|
): Promise<WindowsProcessProbe> {
|
|
const probe = await execRemote(
|
|
sftp,
|
|
psEncoded(
|
|
"$p = @(Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue); " +
|
|
"if ($p.Count -eq 0) { Write-Output 'detenido'; return }; " +
|
|
'$rutas = @(); ' +
|
|
'foreach ($x in $p) { try { if ($x.Path) { $rutas += $x.Path } } catch { } }; ' +
|
|
"Write-Output ('corriendo|' + ($rutas -join ';'))"
|
|
)
|
|
);
|
|
|
|
const out = probe.stdout.trim();
|
|
if (!out.startsWith('corriendo')) {
|
|
return { running: false, paths: [], fromPrefix: false };
|
|
}
|
|
const joined = out.split('|')[1] ?? '';
|
|
const paths = joined.split(';').map((p) => p.trim()).filter(Boolean);
|
|
const wanted = windowsAgentExe(prefix);
|
|
return {
|
|
running: true,
|
|
paths,
|
|
fromPrefix: paths.some((p) => sameWindowsPath(p, wanted))
|
|
};
|
|
}
|
|
|
|
export interface WindowsTaskProbe {
|
|
exists: boolean;
|
|
/** Ruta del ejecutable en la acción de la tarea. Es DÓNDE arranca el agente de verdad. */
|
|
execute: string;
|
|
/** Cuenta con la que corre. `SYSTEM` es lo que obliga a elevar para pararla o arrancarla. */
|
|
principal: string;
|
|
}
|
|
|
|
/**
|
|
* Interroga la tarea programada del agente.
|
|
*
|
|
* Existe porque todo el camino de Windows identificaba al agente por NOMBRE mientras que lo único
|
|
* que se actualiza se identifica por RUTA. `Start-ScheduledTask` ejecuta la ruta registrada en la
|
|
* acción de la tarea, no la que acabamos de instalar: en cuanto las dos no coinciden se reemplaza
|
|
* un binario y se arranca otro, sin que nada falle.
|
|
*
|
|
* Las propiedades se leen por reflexión porque no toda acción de una tarea es de tipo Exec (una
|
|
* ComHandler no tiene `Execute`).
|
|
*/
|
|
export async function probeWindowsTask(sftp: SftpClient): Promise<WindowsTaskProbe> {
|
|
const probe = await execRemote(
|
|
sftp,
|
|
psEncoded(
|
|
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
|
|
"if (-not $t) { Write-Output 'no||'; return }; " +
|
|
"$exec = ''; " +
|
|
'foreach ($a in @($t.Actions)) { ' +
|
|
"$pr = $a.PSObject.Properties['Execute']; " +
|
|
"if ($pr -and $pr.Value) { $exec = ([string]$pr.Value).Trim('\"'); break } }; " +
|
|
"$user = ''; " +
|
|
"try { if ($t.Principal) { $user = [string]$t.Principal.UserId } } catch { }; " +
|
|
"Write-Output ('si|' + $exec + '|' + $user)"
|
|
)
|
|
);
|
|
|
|
const [flag, execute, principal] = probe.stdout.trim().split('|');
|
|
return {
|
|
exists: flag === 'si',
|
|
execute: (execute ?? '').trim(),
|
|
principal: (principal ?? '').trim()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Alinea la acción de la tarea programada con el binario que se acaba de instalar.
|
|
*
|
|
* Esto lo hace el PANEL y no solo el instalador porque un instalador ya publicado no se puede
|
|
* arreglar hacia atrás: `install.ps1` aprendió a realinear la tarea después de que saliera 1.1.3,
|
|
* y el script que se ejecuta en el servidor es el que viaja DENTRO del artefacto. Haciéndolo desde
|
|
* aquí funciona con cualquier versión ya distribuida.
|
|
*
|
|
* Devuelve true si tuvo que cambiar algo. Reapunta solo la acción y conserva disparador, principal,
|
|
* ajustes y argumentos: volver a registrar la tarea desde cero perdería los ajustes que el operador
|
|
* le haya hecho.
|
|
*/
|
|
export async function alignWindowsTask(
|
|
sftp: SftpClient,
|
|
runId: number,
|
|
taskExecute: string,
|
|
targetPath: string
|
|
): Promise<boolean> {
|
|
if (!taskExecute) return false;
|
|
const wanted = windowsAgentExe(targetPath);
|
|
if (sameWindowsPath(taskExecute, wanted)) return false;
|
|
|
|
// `$argumentos` y no `$args`: `$args` es una variable automática de PowerShell y asignarla
|
|
// dentro de un bloque de script es un error.
|
|
const result = await execRemote(
|
|
sftp,
|
|
psEncoded(
|
|
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
|
|
"if (-not $t) { Write-Output 'sin-tarea'; exit 0 }; " +
|
|
'$acciones = @(); ' +
|
|
'foreach ($a in @($t.Actions)) { ' +
|
|
" $pe = $a.PSObject.Properties['Execute']; " +
|
|
' if (-not $pe) { $acciones += $a; continue }; ' +
|
|
" $pa = $a.PSObject.Properties['Arguments']; " +
|
|
" $argumentos = if ($pa -and $pa.Value) { [string]$pa.Value } else { '' }; " +
|
|
' if ($argumentos) { $acciones += New-ScheduledTaskAction ' +
|
|
`-Execute '${wanted}' -Argument $argumentos -WorkingDirectory '${targetPath}' } ` +
|
|
'else { $acciones += New-ScheduledTaskAction ' +
|
|
`-Execute '${wanted}' -WorkingDirectory '${targetPath}' } }; ` +
|
|
"try { Set-ScheduledTask -TaskName 'CloudRestoreAS' -Action $acciones " +
|
|
"-ErrorAction Stop | Out-Null; Write-Output 'ok' } " +
|
|
"catch { Write-Output ('error|' + $_.Exception.Message) }"
|
|
)
|
|
);
|
|
|
|
const out = result.stdout.trim();
|
|
if (out === 'ok') {
|
|
// Se asienta la ruta ANTERIOR además de la nueva: si el operador declaró mal la ruta de
|
|
// instalación, este es el dato con el que se deshace el cambio a mano.
|
|
await appendInstallStep(
|
|
runId,
|
|
'realinear-arranque',
|
|
true,
|
|
`la tarea ejecutaba ${taskExecute}; ahora ejecuta ${wanted}`
|
|
);
|
|
return true;
|
|
}
|
|
|
|
// No se sigue adelante: arrancar a sabiendas el binario de otra carpeta es justo el fallo
|
|
// silencioso que se está persiguiendo.
|
|
throw new InstallError(
|
|
409,
|
|
`La tarea programada de ${targetPath} ejecuta ${taskExecute}, no el binario que se acaba de ` +
|
|
`instalar (${wanted}), y no se pudo corregir` +
|
|
(out.startsWith('error|') ? `: ${truncate(out.slice(6))}` : ' (no hay tarea registrada)') +
|
|
'. Sin eso, el arranque automático seguiría levantando la versión anterior.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reinicia el agente en Windows y espera a que corra el binario de `prefix`.
|
|
*
|
|
* Vive en este módulo y no en `cras-agent-control` porque este es el de más abajo: al revés habría
|
|
* una dependencia circular. `startCrasAgent` lo reutiliza en lugar de tener su propia copia.
|
|
*/
|
|
export async function restartWindowsAgent(
|
|
sftp: SftpClient,
|
|
prefix: string,
|
|
timeoutMs = 60_000
|
|
): Promise<WindowsProcessProbe> {
|
|
const exe = windowsAgentExe(prefix);
|
|
await execRemote(
|
|
sftp,
|
|
psEncoded(
|
|
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
|
|
"if ($t) { Stop-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue }; " +
|
|
"Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue | " +
|
|
'Stop-Process -Force -ErrorAction SilentlyContinue; ' +
|
|
'Start-Sleep -Seconds 3; ' +
|
|
// Si hay tarea se arranca por ella (respeta su principal y su entorno); si no, el
|
|
// proceso suelto. `--headless` hace que el binario elija el plugin Qt offscreen.
|
|
"if ($t) { Start-ScheduledTask -TaskName 'CloudRestoreAS' } " +
|
|
`else { Start-Process -FilePath '${exe}' ` +
|
|
`-ArgumentList '--start-engine','--headless' -WorkingDirectory '${prefix}' ` +
|
|
'-WindowStyle Hidden }'
|
|
),
|
|
INSTALL_EXEC_TIMEOUT_MS
|
|
);
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
let probe = await probeWindowsAgentProcess(sftp, prefix);
|
|
while (Date.now() < deadline) {
|
|
// `paths` vacío con el proceso vivo significa "no pude ver desde dónde corre" (un proceso de
|
|
// SYSTEM no expone su ruta sin elevación): se acepta, no se insiste hasta agotar el margen.
|
|
if (probe.fromPrefix || (probe.running && probe.paths.length === 0)) return probe;
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
probe = await probeWindowsAgentProcess(sftp, prefix);
|
|
}
|
|
return probe;
|
|
}
|
|
|
|
/**
|
|
* Cola del crash log que el agente escribe junto a su ejecutable 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.
|
|
*/
|
|
async function readWindowsCrashLog(sftp: SftpClient, prefix: string): Promise<string> {
|
|
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 }'
|
|
)
|
|
);
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
async function installWindows(
|
|
sftp: SftpClient,
|
|
runId: number,
|
|
release: CrasRelease,
|
|
localPath: string,
|
|
request: InstallRequest,
|
|
target: RestoreTargetSsh,
|
|
installPath: string,
|
|
reportedInstallPath: string | null
|
|
): Promise<void> {
|
|
// --- Precondiciones ------------------------------------------------------
|
|
const systemEvidence = await assertSystemMatches(sftp, 'windows');
|
|
|
|
const privileged = await probeWindowsElevation(sftp);
|
|
const autostart = request.autostart ?? 'service';
|
|
|
|
// La tarea programada se interroga a fondo, no solo si existe. Su acción dice DÓNDE vive
|
|
// realmente el agente, y su principal dice si hace falta elevación para tocarla. Sin ese dato
|
|
// se dio por buena una actualización que reemplazó el binario de una carpeta y arrancó el de
|
|
// otra: el run terminaba en verde y el servidor seguía con la versión anterior.
|
|
const task = await probeWindowsTask(sftp);
|
|
await appendInstallStep(
|
|
runId,
|
|
'arranque-automatico-actual',
|
|
true,
|
|
task.exists
|
|
? `tarea CloudRestoreAS ejecuta ${task.execute || '(sin acción)'}` +
|
|
(task.principal ? ` como ${task.principal}` : '')
|
|
: 'no hay tarea CloudRestoreAS registrada'
|
|
);
|
|
|
|
// Cuando el agente no reporta su ruta —nada anterior a 1.1.1 lo hace— la de la tarea es una
|
|
// fuente REAL del destino, no una suposición. Preferirla al default evita el caso en que el
|
|
// panel apuntaba a C:\Aduanasoft\CloudRestoreAS mientras el agente vivía en otra carpeta.
|
|
let targetPath = installPath;
|
|
if (!reportedInstallPath && task.execute) {
|
|
const fromTask = task.execute.replace(/\\[^\\]*$/, '');
|
|
if (fromTask && isSafeInstallPath(fromTask, 'windows')) {
|
|
targetPath = fromTask;
|
|
await appendInstallStep(
|
|
runId,
|
|
'ruta-desde-la-tarea',
|
|
true,
|
|
`el agente no reporta su ruta; se usa la de la tarea: ${targetPath}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// La elevación hace falta para MANDAR sobre la tarea: registrarla en una instalación nueva, o
|
|
// pararla y arrancarla cuando corre como SYSTEM. Que la tarea EXISTA no basta —si corre con la
|
|
// propia cuenta SSH no hay nada que elevar—, y tratarlo así bloqueaba de entrada cualquier
|
|
// actualización sobre un servidor ya instalado.
|
|
const taskNeedsAdmin = task.exists && /system|S-1-5-18/i.test(task.principal);
|
|
const needsElevation = autostart === 'service' || taskNeedsAdmin;
|
|
|
|
if (needsElevation && privileged.elevation === 'limitado') {
|
|
throw new InstallError(
|
|
409,
|
|
`No se puede operar la tarea programada de ${target.name}: ${privileged.detail}`
|
|
);
|
|
}
|
|
// Con el token filtrado por UAC no se rechaza por adelantado: se intenta y se reporta lo que
|
|
// responda el servidor. La sonda mide `IsInRole`, que en una sesión de OpenSSH puede decir que
|
|
// no aun cuando la operación sí pase; adivinarlo bloqueaba actualizaciones que funcionaban.
|
|
if (needsElevation && privileged.elevation === 'token-filtrado') {
|
|
await appendInstallStep(runId, 'aviso-elevacion', true, privileged.detail);
|
|
}
|
|
await appendInstallStep(
|
|
runId,
|
|
'precondiciones',
|
|
true,
|
|
`${systemEvidence}, privilegios=${privileged.label}`
|
|
);
|
|
|
|
if (request.mode === 'update') {
|
|
await assertExistingInstall(sftp, 'windows', targetPath, 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'}`
|
|
);
|
|
}
|
|
|
|
// Actualizar va por -UpdateInPlace, sea cual sea el arranque configurado: no vuelve a
|
|
// registrar la tarea (así no pisa ajustes que el operador haya hecho sobre ella), se salta
|
|
// el bootstrap —una segunda instancia purgaría el Temp\ de la que está viva— y trae respaldo
|
|
// con reversión automática si el binario nuevo no levanta.
|
|
//
|
|
// Pero solo si el instalador DEL ARTEFACTO lo declara: el parámetro existe desde 1.1.3, e
|
|
// install.ps1 usa [CmdletBinding()], así que pasárselo a uno anterior falla con
|
|
// NamedParameterNotFound SIN ejecutar una sola línea. Se le pregunta a PowerShell por el
|
|
// `param()` del propio script en vez de mantener una tabla de versiones aquí.
|
|
const installerPs1 = `${remoteDir}\\CloudRestoreAS\\install.ps1`;
|
|
const supportsProbe = await execRemote(
|
|
sftp,
|
|
psEncoded(
|
|
`if ((Get-Command '${installerPs1}').Parameters.ContainsKey('UpdateInPlace')) ` +
|
|
"{'si'} else {'no'}"
|
|
)
|
|
);
|
|
const supportsInPlace = supportsProbe.stdout.trim() === 'si';
|
|
const inPlaceUpdate = request.mode === 'update' && supportsInPlace;
|
|
if (request.mode === 'update' && !supportsInPlace) {
|
|
await appendInstallStep(
|
|
runId,
|
|
'instalador-sin-update-in-place',
|
|
true,
|
|
`${release.version} trae un install.ps1 que no soporta -UpdateInPlace; se instala ` +
|
|
'con el modo de arranque normal'
|
|
);
|
|
}
|
|
const flag = inPlaceUpdate
|
|
? ' -UpdateInPlace'
|
|
: autostart === 'none'
|
|
? ''
|
|
: 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 '${targetPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE`
|
|
),
|
|
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,
|
|
`install.ps1 terminó con código ${install.code}: ${truncate(install.stderr || install.stdout)}`
|
|
);
|
|
}
|
|
|
|
// 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)) {
|
|
const proc = await restartWindowsAgent(sftp, targetPath);
|
|
await appendInstallStep(
|
|
runId,
|
|
'rearrancar-agente',
|
|
proc.running,
|
|
proc.running
|
|
? `corriendo desde ${proc.paths.join(', ') || '(ruta no legible)'}`
|
|
: 'el agente no volvió a arrancar tras realinear la tarea'
|
|
);
|
|
}
|
|
|
|
await verifyWindowsDeployment(
|
|
sftp, runId, release, autostart, targetPath, inPlaceUpdate, request.mode === 'update'
|
|
);
|
|
} finally {
|
|
await cleanupStaging(
|
|
sftp,
|
|
runId,
|
|
remoteDir,
|
|
psEncoded(
|
|
`Remove-Item -LiteralPath '${remoteDir}' -Recurse -Force -ErrorAction Stop`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function verifyWindowsDeployment(
|
|
sftp: SftpClient,
|
|
runId: number,
|
|
release: CrasRelease,
|
|
autostart: AutostartMode,
|
|
prefix: string,
|
|
inPlaceUpdate = false,
|
|
isUpdate = false
|
|
): Promise<void> {
|
|
// 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 = '';
|
|
// Margen generoso: desempacar un onefile de ~270 MB con Defender escaneando cada archivo se
|
|
// pasa de largo de los 30s que había antes, y agotar el margen ahora es un FALLO en una
|
|
// actualización, así que quedarse corto convertiría un despliegue bueno en un error.
|
|
const stampAttempts = isUpdate || inPlaceUpdate ? 45 : 3;
|
|
for (let attempt = 0; attempt < stampAttempts; attempt += 1) {
|
|
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
const stamp = await execRemote(
|
|
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}. ` +
|
|
(inPlaceUpdate
|
|
? 'El sello no cambió tras reiniciar el agente, así que el proceso puede seguir ' +
|
|
'ejecutando el binario anterior.'
|
|
: '')
|
|
);
|
|
}
|
|
// En una ACTUALIZACIÓN, un sello ausente es un fallo, no un aviso.
|
|
//
|
|
// Esto es lo que dejaba pasar una actualización que no hacía nada. Los agentes anteriores a
|
|
// 1.1.1 no escribían config\.version, así que `deployed` llegaba vacío, el vacío se toleraba y
|
|
// el paso se asentaba como "se creará al arrancar": en la única combinación que importa
|
|
// —actualizar un agente viejo— no se comprobaba ninguna versión. El binario nuevo SÍ lo escribe
|
|
// en ensure_runtime_layout(), así que si tras todo el margen sigue sin aparecer, lo que corre
|
|
// no es el binario que acabamos de instalar. La tolerancia se queda solo en instalación nueva,
|
|
// que es donde sí es legítima.
|
|
if (!deployed && isUpdate) {
|
|
// El mensaje trae el diagnóstico ya hecho. El panel tiene a mano a qué apunta la tarea,
|
|
// desde dónde corre el proceso y el crash log del agente; mandar al operador a "revisar el
|
|
// arranque automático" le pedía averiguar algo que aquí ya se sabe — y la bitácora del run,
|
|
// donde sí estaba, no la encuentra nadie.
|
|
const [task, proc, crash] = await Promise.all([
|
|
probeWindowsTask(sftp),
|
|
probeWindowsAgentProcess(sftp, prefix),
|
|
readWindowsCrashLog(sftp, prefix)
|
|
]);
|
|
|
|
const pistas = [
|
|
task.exists
|
|
? `el arranque automático ejecuta ${task.execute || '(sin acción)'}`
|
|
: 'no hay tarea CloudRestoreAS registrada, así que nadie levanta el agente',
|
|
proc.running
|
|
? `hay un proceso corriendo desde ${proc.paths.join(', ') || '(ruta no legible)'}`
|
|
: 'no hay ningún proceso CloudRestoreAS corriendo'
|
|
];
|
|
|
|
throw new InstallError(
|
|
502,
|
|
`El agente de ${prefix} no escribió config\\.version tras la actualización. La versión ` +
|
|
`nueva lo escribe al arrancar, así que lo que está corriendo no es ${release.version}. ` +
|
|
`${pistas.join('; ')}.` +
|
|
(crash ? ` Último crash del agente: ${truncate(crash)}` : '') +
|
|
` Logs en ${prefix}\\config\\logs.`
|
|
);
|
|
}
|
|
await appendInstallStep(
|
|
runId,
|
|
'verificar-version',
|
|
true,
|
|
deployed ? `config\\.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)'
|
|
);
|
|
|
|
// 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 probeWindowsAgentProcess(sftp, prefix);
|
|
const running = proc.running;
|
|
|
|
// Corriendo, sí, pero ¿el binario de ESTA instalación? Preguntar solo por el nombre respondía
|
|
// "hay un proceso llamado CloudRestoreAS", que el agente viejo —nunca detenido, arrancado por
|
|
// una tarea que apuntaba a otra carpeta— satisfacía igual de bien.
|
|
if (running && proc.paths.length > 0 && !proc.fromPrefix) {
|
|
throw new InstallError(
|
|
502,
|
|
`Hay un agente corriendo desde ${proc.paths.join(', ')}, no desde ${prefix}. Se instaló ` +
|
|
'el binario nuevo en una carpeta y el arranque automático levanta el de otra, así que ' +
|
|
'la actualización no surtió efecto. Revisa a qué ejecutable apunta la tarea ' +
|
|
'CloudRestoreAS en el servidor.'
|
|
);
|
|
}
|
|
|
|
if (autostart !== 'service') {
|
|
// Sin tarea que consultar, el proceso es lo único que hay. En 'desktop' el agente arranca
|
|
// 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${proc.paths.length ? ` desde ${proc.paths.join(', ')}` : ''}`
|
|
: 'CloudRestoreAS no está en ejecución'
|
|
);
|
|
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.'
|
|
);
|
|
}
|
|
// `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` +
|
|
(proc.paths.length ? ` desde ${proc.paths.join(', ')}` : ' (ruta no legible)')
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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<void> {
|
|
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;
|
|
}
|