Files
PANEL_BASES_ANEXO24/src/lib/server/cras-install.ts
hreyes bd93f49113
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
fix(cras-install): el panel no puede creerse un exito que no ocurrio (#30)
La bitacora del run 26 tenia la respuesta: la sonda decia que install.ps1 NO
EXISTE y el paso siguiente, "Ejecutar instalador", salia en verde. Comprobado con
PowerShell real por que:

    & 'C:\no-existe.ps1' -Service; exit $LASTEXITCODE   ->   exit 0

$LASTEXITCODE no se fija nunca (no corrio ningun comando nativo), asi que
`exit $null` da 0. El panel leia 0 y asentaba el paso como exitoso. NADA se
instalaba, y por eso seguia viva la 1.1.0: el agente viejo nunca se detuvo porque
el instalador nunca se ejecuto. Esa evidencia falsa mando el diagnostico a otra
parte durante varios runs.

- El script emite un CENTINELA `CRAS-FIN|<codigo>` y el panel lo EXIGE. Sin el, el
  instalador no termino, de lo que de el codigo de salida. Un fallo no es una
  respuesta — la misma leccion que la sonda, ahora en la invocacion.
  Validado con PowerShell real: instalador normal -> CRAS-FIN|0; instalador que
  sale 75 -> CRAS-FIN|75 (la traduccion a "restauracion en curso" sigue viva);
  instalador inexistente -> CRAS-ERROR| y codigo 90 en vez de un 0 silencioso.
- "El instalador no esta" pasa a ser un hecho aparte de "no pude leerlo", y aborta
  con 502. Antes se colapsaba en `desconocido`, el run continuaba sabiendolo ya,
  gastaba el intento entero y acababa culpando al agente de no escribir su sello.
- Se asienta el CONTENIDO del staging tras extraer. Sin eso no se distinguia "el
  instalador no esta", "esta en otra ruta" y "esta pero no se puede leer", que son
  tres arreglos distintos.

Y dos cambios en el endurecimiento del token, cada uno defendible por si solo:

- Se restringe SOLO panel.env, no la carpeta. La restriccion de la carpeta era un
  extra —su motivo declarado, que el archivo heredara una ACL permisiva al
  crearse, ya lo cubre la ACE del propio archivo— y a cambio dejaba la carpeta con
  ACE NO heredables, de modo que lo creado dentro despues podia quedar sin
  permisos utilizables. Ahi se extrae el artefacto: era candidato serio a explicar
  por que el instalador "no existia".
- Por SID y no por nombre de grupo. En un Windows en espanol
  `BUILTIN\Administrators` no resuelve y el icacls falla entero; comprobado.
  S-1-5-32-544 y S-1-5-18 valen en cualquier idioma.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Reviewed-on: #30
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
2026-07-31 21:49:11 +00:00

2150 lines
98 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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' | 'desconocido';
export interface WindowsPrivilege {
elevation: WindowsElevation;
label: string;
/** Qué falta exactamente, en términos accionables. Vacío cuando sí hay elevación. */
detail: string;
}
/**
* Modo de lenguaje de PowerShell en el destino.
*
* Existe porque en `ConstrainedLanguage` —lo que imponen AppLocker o WDAC, habituales en servidores
* endurecidos— PowerShell prohíbe resolver tipos .NET y llamar a métodos estáticos, mientras los
* cmdlets y `& script.ps1` siguen funcionando. Eso convierte fallos en sitios sorprendentes: fue la
* hipótesis principal cuando `(Get-Command …).Parameters` respondía en una máquina de desarrollo y
* no en el servidor real, y `install.ps1` todavía usa varias construcciones que ahí no funcionarían
* (`[System.IO.Path]::GetFullPath`, `New-Object System.Text.UTF8Encoding`, `[TimeSpan]::Zero`…).
*
* Se registra en la bitácora del run SIEMPRE, cueste lo que cueste averiguarlo, para dejar de
* diagnosticar a ciegas: sin este dato cada fallo raro en Windows arranca con una ronda de
* suposiciones. Devuelve cadena vacía si ni siquiera esto se pudo consultar.
*/
export async function probeWindowsLanguageMode(sftp: SftpClient): Promise<string> {
const probe = await execRemote(
sftp,
psEncoded('Write-Output $ExecutionContext.SessionState.LanguageMode')
);
return probe.code === 0 ? probe.stdout.trim() : '';
}
/**
* 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"}'
)
);
const salida = probe.stdout.trim();
// Sin respuesta no se afirma que la cuenta sea limitada: ese veredicto gobierna un 409, y
// deducirlo de un comando que no contestó rechazaría instalaciones perfectamente válidas.
if (probe.code !== 0 || !salida) {
return {
elevation: 'desconocido',
label: 'privilegios no determinados',
detail:
`la sonda de privilegios no respondió (código ${probe.code})` +
(probe.stderr ? `: ${truncate(probe.stderr)}` : '') +
'. Se continúa: el propio instalador fallará con un mensaje claro si de verdad ' +
'faltan permisos.'
};
}
switch (salida) {
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 {
/**
* La sonda obtuvo una respuesta del destino. En false NO se sabe si hay agente corriendo, y
* quien decida un fallo duro debe exigir una respuesta afirmativa en vez de asumirla por
* ausencia: leer "el comando no respondió" como "no hay proceso" tumba actualizaciones buenas.
*/
answered: boolean;
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();
// Sin respuesta no se sabe nada. Antes esto se leía como "detenido", que es una afirmación que
// la sonda nunca hizo.
if (probe.code !== 0 || !out) {
return { answered: false, running: false, paths: [], fromPrefix: false };
}
if (!out.startsWith('corriendo')) {
return { answered: true, 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 {
answered: true,
running: true,
paths,
fromPrefix: paths.some((p) => sameWindowsPath(p, wanted))
};
}
export interface WindowsTaskProbe {
/** La sonda respondió. En false, `exists: false` significa "no sé", no "no hay tarea". */
answered: boolean;
exists: boolean;
/** Ruta del ejecutable en la acción de la tarea. Es DÓNDE arranca el agente de verdad. */
execute: string;
/** 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 salida = probe.stdout.trim();
if (probe.code !== 0 || !salida) {
return { answered: false, exists: false, execute: '', principal: '' };
}
const [flag, execute, principal] = salida.split('|');
return {
answered: true,
exists: flag === 'si',
execute: (execute ?? '').trim(),
principal: (principal ?? '').trim()
};
}
/**
* Respuesta de una sonda que puede no saber.
*
* `desconocido` NO es `no`. Confundirlos es lo que dejó bloqueadas las actualizaciones con un
* artefacto perfectamente bueno: la sonda anterior colapsaba cualquier fallo —una excepción, un
* código de salida distinto de cero, stdout vacío— en "el instalador no lo soporta", y sobre esa
* lectura se decidía un 409. Mientras solo elegía entre dos banderas el falso negativo degradaba;
* en cuanto gobernó un rechazo, se volvió un candado.
*/
export interface ProbeAnswer {
/**
* `sin-archivo` está aparte de `desconocido` a propósito: que el instalador no exista **sí** es
* una respuesta definitiva, y de las peores —no hay nada que ejecutar—, mientras `desconocido`
* es "no pude leerlo". Colapsarlos hacía que el run siguiera adelante sabiendo ya que no había
* instalador, y gastara el resto del intento para acabar culpando al agente.
*/
answer: 'si' | 'no' | 'desconocido' | 'sin-archivo';
/** Por qué no se pudo determinar. Vacío cuando hay respuesta. */
detail: string;
}
/**
* ¿El `install.ps1` del artefacto declara `-UpdateInPlace`?
*
* Se pregunta leyendo el TEXTO del script, no pidiéndole a PowerShell que lo compile y exponga sus
* parámetros. `(Get-Command …).Parameters` funcionaba en una máquina de desarrollo y fallaba en el
* servidor real; el candidato más probable es Constrained Language Mode (AppLocker/WDAC son
* habituales en servidores endurecidos), que restringe el acceso a propiedades de objetos .NET
* mientras `& install.ps1` sigue funcionando — justo lo que se observaba. Un `-match` sobre el
* contenido no compila nada, no toca reflexión y ninguna política lo bloquea.
*
* Se ancla a la DECLARACIÓN (`[switch]$UpdateInPlace`) y no a una mención suelta, para que un
* comentario que nombre el parámetro no dé un falso positivo.
*/
export async function probeInstallerUpdateSupport(
sftp: SftpClient,
installerPath: string
): Promise<ProbeAnswer> {
const probe = await execRemote(
sftp,
psEncoded(
`$p = '${installerPath}'; ` +
"if (-not (Test-Path -LiteralPath $p)) { Write-Output 'sin-archivo'; exit 0 }; " +
'try { ' +
// Select-String y nada más: un cmdlet puro, sin resolución de tipos .NET.
// La versión anterior usaba [regex]::IsMatch, que es una llamada estática y por
// tanto de lo primero que bloquea Constrained Language Mode — el modo que se
// sospecha en el servidor donde falló `(Get-Command …).Parameters`. Habría
// degradado a 'desconocido' en vez de responder, que no rompe nada pero tampoco
// sirve de nada.
// `-ErrorAction Stop` NO es opcional: Select-String emite un error NO TERMINANTE
// cuando no puede leer el archivo, así que sin él el catch no se dispara, -Quiet
// devuelve falso y el fallo de lectura se reporta como 'no'. Es decir, exactamente
// el defecto que esta función existe para eliminar. Lo destapó probar la ruta de
// fallo con PowerShell real, no las pruebas unitarias.
"if (Select-String -LiteralPath $p -Pattern '\\[switch\\]\\s*\\$UpdateInPlace' " +
'-Quiet -ErrorAction Stop) ' +
"{ Write-Output 'si' } else { Write-Output 'no' } } " +
"catch { Write-Output ('desconocido|' + $_.Exception.Message) }"
)
);
const salida = probe.stdout.trim();
// El código de salida y stderr cuentan: un execRemote que falla deja stdout vacío, y leer eso
// como una respuesta es precisamente el error que se está corrigiendo.
if (probe.code !== 0 || !salida) {
return {
answer: 'desconocido',
detail:
`la sonda no respondió (código ${probe.code})` +
(probe.stderr ? `: ${truncate(probe.stderr)}` : '')
};
}
if (salida === 'si' || salida === 'no') return { answer: salida, detail: '' };
if (salida === 'sin-archivo') {
return { answer: 'sin-archivo', detail: `no existe ${installerPath}` };
}
return {
answer: 'desconocido',
detail: salida.startsWith('desconocido|') ? salida.slice(12) : truncate(salida)
};
}
/**
* ¿Se puede desplegar este artefacto en Windows sin romper el servidor?
*
* Que su `install.ps1` no declare `-UpdateInPlace` significa que el paquete se construyó ANTES de los
* arreglos de actualización, y usarlo no es "una actualización peor": es dejar el servidor sin agente.
* Ese instalador detiene la tarea y mata los procesos, corre el bootstrap acotado a 20 s —insuficiente
* para desempacar un onefile de ~250 MB con el antivirus escaneando, así que muere antes de escribir
* `config\.version`—, re-registra la tarea como SYSTEM y la arranca sin comprobar que volviera. Y el
* binario del mismo paquete lleva el `runner.py` que ignora `--headless` en Windows, así que como
* SYSTEM en la sesión 0 Qt no puede crear su plataforma y el agente muere. Sin agente viejo y sin
* agente nuevo.
*
* Lanza 409 al ACTUALIZAR: negarse y decir por qué es estrictamente mejor que romperlo y explicarlo
* después. En una instalación NUEVA devuelve el aviso para asentarlo, porque ahí no hay agente que
* perder y negarse solo estorbaría.
*
* Es una función aparte, y no una comprobación dentro de `installWindows`, porque es una política con
* consecuencias: merece nombre propio y prueba propia.
*/
export function checkWindowsArtifactUpdateSupport(
mode: InstallMode,
soporte: ProbeAnswer,
version: string,
targetName: string
): string {
if (soporte.answer === 'si') return '';
// Que el instalador NO ESTÉ no es una duda: es el peor de los hechos posibles, porque no hay
// nada que ejecutar. Antes se colapsaba en `desconocido` y el run seguía adelante sabiéndolo ya,
// gastaba el resto del intento y acababa culpando al agente de no escribir su sello.
if (soporte.answer === 'sin-archivo') {
throw new InstallError(
502,
`El artefacto ${version} se extrajo en ${targetName} pero no dejó el instalador donde ` +
`se esperaba: ${soporte.detail}. No hay nada que ` +
'ejecutar, así que no se toca el servidor. Revisa el paso "contenido-staging" de ' +
'este run para ver qué sí quedó en la carpeta temporal.'
);
}
// No saber NO es motivo para bloquear. Un fallo de diagnóstico no puede impedir el trabajo: se
// sigue con el comportamiento anterior y se deja dicho por qué no se pudo determinar, que es el
// dato que convierte la próxima sorpresa en un diagnóstico de diez segundos.
if (soporte.answer === 'desconocido') {
return (
`No se pudo determinar si el install.ps1 de ${version} soporta -UpdateInPlace ` +
`(${soporte.detail || 'sin detalle'}). Se instala con el modo de arranque normal en vez ` +
'de bloquear: no saberlo no es lo mismo que saber que no.'
);
}
const aviso =
`El artefacto ${version} trae un install.ps1 anterior a los arreglos de actualización ` +
'(no declara -UpdateInPlace).';
if (mode === 'update') {
throw new InstallError(
409,
`${aviso} Actualizar ${targetName} con él dejaría el servidor SIN agente: ese instalador ` +
'detiene el que está corriendo, cambia la tarea programada a SYSTEM y no comprueba ' +
'que vuelva a arrancar — y su binario no sabe correr headless en Windows, así que ' +
'como SYSTEM no levanta. Publica una versión construida con los arreglos y ' +
'actualiza a esa.'
);
}
return `${aviso} Se instala igual: no hay un agente en marcha que perder.`;
}
/**
* Alinea la acción de la tarea programada con el binario que se acaba de instalar.
*
* 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 cuando no consigue arrancar.
*
* Se buscan **las tres** ubicaciones que usa `runner.py` (`_crash_log_targets()`), en su mismo orden
* de preferencia: junto al ejecutable, `%LOCALAPPDATA%\CloudRestoreAS\crash.log` y
* `%TEMP%\CloudRestoreAS-crash.log`.
*
* Mirar solo junto al ejecutable no bastaba, y era justo el caso que interesa: un agente lanzado por
* una tarea que corre como SYSTEM resuelve las otras dos bajo
* `C:\Windows\System32\config\systemprofile\`, y ahí es donde quedó la evidencia del arranque que
* fallaba. El operador recibía "no arrancó" en lugar de "no arrancó porque X".
*
* Las variables se expanden EN EL DESTINO y en la sesión del usuario SSH, así que si el agente corre
* como SYSTEM su `%LOCALAPPDATA%` no es el mismo: por eso se añade explícitamente el del perfil de
* SYSTEM en vez de confiar en la expansión.
*/
async function readWindowsCrashLog(
sftp: SftpClient,
prefix: string
): Promise<{ text: string; from: string }> {
const candidatos = [
`${prefix.replace(/\\+$/, '')}\\CloudRestoreAS-crash.log`,
'$env:LOCALAPPDATA\\CloudRestoreAS\\crash.log',
'$env:TEMP\\CloudRestoreAS-crash.log',
// El perfil de SYSTEM, que es donde caen los dos anteriores cuando el agente lo lanza la
// tarea programada como SYSTEM y no la sesión SSH.
'C:\\Windows\\System32\\config\\systemprofile\\AppData\\Local\\CloudRestoreAS\\crash.log',
'C:\\Windows\\Temp\\CloudRestoreAS-crash.log'
];
const result = await execRemote(
sftp,
psEncoded(
`$rutas = @("${candidatos.join('","')}"); ` +
'foreach ($r in $rutas) { ' +
'if (Test-Path -LiteralPath $r) { ' +
"Write-Output ('DE|' + $r); " +
'(Get-Content -LiteralPath $r -Tail 20) -join [Environment]::NewLine; break } }'
)
);
const salida = result.stdout.trim();
if (!salida.startsWith('DE|')) return { text: '', from: '' };
const [cabecera, ...resto] = salida.split('\n');
return { text: resto.join('\n').trim(), from: cabecera.slice(3).trim() };
}
async function installWindows(
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);
}
// El modo de lenguaje se registra SIEMPRE, aunque todo vaya bien. Es barato y es el dato que
// faltaba: en ConstrainedLanguage varias construcciones de install.ps1 no funcionan, y sin
// verlo en la bitácora cada fallo raro en Windows empieza con una ronda de suposiciones.
const languageMode = await probeWindowsLanguageMode(sftp);
await appendInstallStep(
runId,
'precondiciones',
true,
`${systemEvidence}, privilegios=${privileged.label}` +
`, PowerShell=${languageMode || 'modo no determinado'}`
);
if (languageMode && languageMode !== 'FullLanguage') {
await appendInstallStep(
runId,
'aviso-modo-restringido',
true,
`PowerShell corre en ${languageMode}. Ahí se prohíbe resolver tipos .NET, y install.ps1 ` +
'usa varias construcciones que dependen de eso, así que puede fallar en sitios ' +
'inesperados aunque el artefacto sea correcto.'
);
}
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 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.
//
// Se restringe SOLO el archivo, no la carpeta. La restricción de la carpeta era un extra —su
// motivo declarado, que el archivo heredara una ACL permisiva al crearse, ya lo cubre la ACE
// del propio archivo— y a cambio dejaba la carpeta con ACE **no heredables**, de modo que
// todo lo creado dentro después podía quedar sin permisos utilizables. Ahí se extrae el
// artefacto, así que era un candidato serio a explicar por qué el instalador "no existía".
//
// Y por SID, no por nombre de grupo: en un Windows en español `BUILTIN\Administrators` no
// resuelve y el icacls falla entero ("No se efectuó ninguna asignación entre los nombres de
// cuenta y los identificadores de seguridad"). Comprobado. S-1-5-32-544 es Administradores y
// S-1-5-18 es SYSTEM en cualquier idioma.
const acl = await execRemote(
sftp,
psEncoded(
`icacls '${remoteEnv}' /inheritance:r ` +
'/grant:r "*S-1-5-32-544:(F)" /grant:r "*S-1-5-18:(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'}`
);
}
// Qué dejó realmente la extracción, asentado en la bitácora. Sin esto no se distinguía
// "el instalador no está", "está en otra ruta" y "está pero no se puede leer" — y son tres
// arreglos distintos. La sonda decía "no existe el archivo" y no había forma de saber cuál
// de los tres era.
const listado = await execRemote(
sftp,
psEncoded(
`Get-ChildItem -LiteralPath '${remoteDir}' -Force -ErrorAction SilentlyContinue | ` +
'ForEach-Object { $_.Name } | Sort-Object'
)
);
await appendInstallStep(
runId,
'contenido-staging',
true,
listado.stdout.trim().split('\n').map((l) => l.trim()).filter(Boolean).join(', ') ||
`no se pudo listar ${remoteDir} (código ${listado.code})`
);
// 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.
const installerPs1 = `${remoteDir}\\CloudRestoreAS\\install.ps1`;
const soporte = await probeInstallerUpdateSupport(sftp, installerPs1);
const avisoArtefacto = checkWindowsArtifactUpdateSupport(
request.mode,
soporte,
release.version,
target.name
);
if (avisoArtefacto) {
await appendInstallStep(runId, 'instalador-antiguo', true, avisoArtefacto);
}
const inPlaceUpdate = request.mode === 'update' && soporte.answer === 'si';
const flag = inPlaceUpdate
? ' -UpdateInPlace'
: autostart === 'none'
? ''
: autostart === 'service'
? ' -Service'
: ' -Desktop';
await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`);
// El script emite un CENTINELA y el panel lo exige.
//
// Antes se invocaba con `& '<ruta>' …; exit $LASTEXITCODE`, y eso no distingue "corrió y
// salió 0" de "no llegó a correr": si la invocación falla —porque el archivo no está, por
// ejemplo— `$LASTEXITCODE` no se fija nunca (no corrió ningún comando nativo), así que
// `exit $null` da 0. Comprobado con PowerShell real. El panel leía 0, asentaba
// "Ejecutar instalador ✓", y el servidor seguía intacto con su agente viejo — durante
// varios runs, con esa evidencia falsa mandando el diagnóstico a otra parte.
//
// Con el centinela, la ausencia de CRAS-FIN significa "no terminó", dé lo que dé el código.
const install = await execRemote(
sftp,
psEncoded(
"$ErrorActionPreference = 'Stop'; " +
'try { ' +
`& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` +
`-Prefix '${targetPath}' -PanelEnvFile '${remoteEnv}'; ` +
'$c = 0; if ($null -ne $LASTEXITCODE) { $c = $LASTEXITCODE } } ' +
"catch { Write-Output ('CRAS-ERROR|' + $_.Exception.Message); exit 90 }; " +
"Write-Output ('CRAS-FIN|' + $c); exit $c"
),
INSTALL_EXEC_TIMEOUT_MS
);
const salidaInstalador = `${install.stdout}\n${install.stderr}`;
if (!salidaInstalador.includes('CRAS-FIN|')) {
const detalle = salidaInstalador.includes('CRAS-ERROR|')
? salidaInstalador.split('CRAS-ERROR|')[1].split('\n')[0].trim()
: truncate(install.stderr || install.stdout) || 'sin salida';
throw new InstallError(
502,
`install.ps1 no llegó a terminar en ${target.name}: ${detalle}. El servidor quedó ` +
'como estaba. Revisa el paso "contenido-staging" de este run para ver qué dejó ' +
'la extracción del artefacto.'
);
}
// 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 instalador puede haber cambiado la CUENTA con la que corre la tarea sin decir nada:
// `-Service` la re-registra como SYSTEM. Ese salto de una cuenta con escritorio a SYSTEM en la
// sesión 0 es lo que impide arrancar a un binario que no sabe caer a Qt offscreen, así que
// cuando pasa hay que dejarlo asentado en vez de que se descubra a base de diagnóstico.
const taskDespues = await probeWindowsTask(sftp);
if (task.exists && taskDespues.exists && task.principal !== taskDespues.principal) {
await appendInstallStep(
runId,
'cambio-de-cuenta-de-la-tarea',
true,
`el instalador cambió la cuenta de la tarea: ${task.principal || '(sin declarar)'}` +
`${taskDespues.principal || '(sin declarar)'}`
);
}
// El binario nuevo ya está en targetPath. Ahora hay que asegurar que el arranque automático
// apunte AHÍ: el install.ps1 de los artefactos publicados hasta 1.1.3 no sabe realinear la
// tarea, y sin eso `Start-ScheduledTask` levanta el binario de la carpeta anterior — la
// actualización termina en verde sin haber cambiado nada.
if (await alignWindowsTask(sftp, runId, taskDespues.execute || 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 = '';
// ~5 minutos al actualizar. Los 88s de antes (45 × 2s) se quedaban cortos con un caso real: en
// los agentes hasta 1.1.4 el sello se escribía DETRÁS del re-despliegue de las deps embebidas,
// que al cambiar de versión se re-copian enteras, y eso va después de desempacar un onefile de
// ~254 MB con el antivirus escaneando. Se reportaba como fallida una actualización que iba
// bien. Desde 1.1.5 el agente escribe el sello lo primero y esto sobra, pero el margen tiene
// que cubrir los artefactos YA publicados, que no se pueden arreglar hacia atrás.
//
// Agotarlo es un FALLO en una actualización, así que quedarse corto convierte un despliegue
// bueno en un error — el error más caro de los dos.
const stampAttempts = isUpdate || inPlaceUpdate ? 150 : 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.text ? ` Último crash (${crash.from}): ${truncate(crash.text)}` : '') +
` 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;
}