Dos huecos de diagnóstico que hacían pasar por sano lo que no lo estaba. 1. El check `config` usaba `test -f`, que comprueba existencia y no lectura, así que salía verde exactamente en el caso roto: una instalación con sudo deja config/.env en 0600 de root mientras el unit corre como una cuenta común, que no puede leerlo. El agente no arranca y la pantalla decía "Configuración presente: ok". Ahora se comprueba lectura y, además, el dueño frente al User= del unit — porque la sonda entra con la cuenta SSH, que no siempre es la del servicio, y un `test -r` desde la sesión no responde por el agente. El check se renombra a "Configuración legible por el servicio", que es lo que de verdad mide, y falla nombrando a los dos usuarios para que el remedio sea obvio. El cálculo del bit de lectura octal sale a una función pura probada: 0600 no deja leer a nadie más que al dueño, se mira el bit 4 y no el valor (620 y 611 no son lectura), se ignora el dígito de setuid, y un modo ilegible NO se interpreta como permisivo — asumir lectura cuando stat devuelve '-' sería el error caro. La lectura de propiedades del unit se extrae a un helper compartido con el instalador. Ya hubo una divergencia por copiar esta lógica: la resolución de elevación existía duplicada y las dos pantallas acabaron diciendo cosas distintas del mismo servidor. 2. Ningún rechazo de token de servicio dejaba rastro: los seis endpoints solo logueaban el 500 de "token no configurado". Con el token rotado, todos los agentes quedan mudos —dejan de reportar versión, resolver rutas y registrar resultados— y desde el panel se ve igual que un agente apagado; además el trace_id que se le devuelve al agente no existía del lado servidor, así que era imposible correlacionar. Se agrega un helper que loguea el rechazo con trace_id, ruta e instancia, y distingue "sin header Authorization" de "token no coincide", que son un agente sin configurar y un token rotado: dos problemas con remedios distintos. El token NUNCA se registra, ni un fragmento suyo — es el mismo valor en todos los agentes, así que un prefijo en los logs ya acota el espacio de búsqueda. Hay prueba de eso. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1200 lines
51 KiB
TypeScript
1200 lines
51 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.
|
|
* - **Nunca se le pasa el password a sudo**, ni por argv ni por stdin. Para los modos que
|
|
* instalan en /opt y registran un servicio de sistema se exige root o `sudo -n` (sudo sin
|
|
* password), y si no hay ninguno se aborta ANTES de subir 270 MB. El modo `user-service`
|
|
* evita el problema en vez de rodearlo: instala bajo el home con un unit de systemd de
|
|
* usuario, así que no necesita elevación alguna y le bastan las credenciales SSH.
|
|
* - **El sha256 se verifica en el destino** antes de extraer, no solo al cachear: así se
|
|
* detecta una transferencia corrupta.
|
|
* - **El progreso se persiste paso a paso** en cras_install_runs.steps para que la UI lo
|
|
* pueda seguir; en AServers los pasos solo quedan en el log del backend.
|
|
*/
|
|
import fs from 'node:fs/promises';
|
|
import SftpClient from 'ssh2-sftp-client';
|
|
import type { Client as SshClient } from 'ssh2';
|
|
|
|
import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg';
|
|
import {
|
|
appendInstallStep,
|
|
finishInstallRun,
|
|
getCrasReleaseById,
|
|
InstallRunConflictError,
|
|
listCrasTargetInventory,
|
|
startInstallRun,
|
|
type CrasRelease,
|
|
type InstallMode
|
|
} from './cras-releases';
|
|
import { ensureCached } from './cras-artifacts';
|
|
import type { AutostartMode } from '$lib/cras-install-progress';
|
|
import { logger } from './logger';
|
|
import {
|
|
effectiveArch,
|
|
effectivePlatform,
|
|
effectiveInstallPath,
|
|
isSafeInstallPath,
|
|
isInsideWorkFolder
|
|
} from '$lib/cras-version';
|
|
|
|
/** Timeouts. La subida no lleva timeout propio: son cientos de MB por un enlace variable. */
|
|
const CONNECT_TIMEOUT_MS = 20_000;
|
|
const EXEC_TIMEOUT_MS = 120_000;
|
|
/** El bootstrap del binario corre con `timeout 20` del lado del destino; se da margen. */
|
|
const INSTALL_EXEC_TIMEOUT_MS = 300_000;
|
|
|
|
export class InstallError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
message: string
|
|
) {
|
|
super(message);
|
|
this.name = 'InstallError';
|
|
}
|
|
}
|
|
|
|
export interface ExecResult {
|
|
code: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
/**
|
|
* Ejecuta un comando por SSH sobre la conexión que ya abrió el cliente SFTP.
|
|
*
|
|
* `ssh2-sftp-client` envuelve un `ssh2.Client` y lo expone en `.client`, pero sus tipos no
|
|
* lo declaran; de ahí el cast. Se reutiliza esa conexión en lugar de abrir una segunda para
|
|
* no autenticarse dos veces por instalación.
|
|
*/
|
|
export function execRemote(
|
|
sftp: SftpClient,
|
|
command: string,
|
|
timeoutMs = EXEC_TIMEOUT_MS
|
|
): Promise<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;
|
|
}
|
|
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í: ahí la falta de privilegios se aborta aquí,
|
|
// antes de transferir 270 MB, porque fallar después es desperdicio y deja basura en /tmp.
|
|
const autostartMode = request.autostart ?? 'service';
|
|
const privileged = await probeLinuxElevation(sftp);
|
|
const canElevate = privileged.elevation === 'root' || privileged.elevation === 'sudo-sin-password';
|
|
|
|
// Sin elevación quedan dos vías antes de rendirse, y ambas evitan pedirle la contraseña a
|
|
// sudo. `user-service` instala en el home; la actualización en sitio deja el unit como está
|
|
// y solo reemplaza el binario, que es lo único que una actualización necesita de verdad.
|
|
let inPlaceUpdate = false;
|
|
if (!canElevate && autostartMode !== 'user-service') {
|
|
if (request.mode !== 'update') {
|
|
throw new InstallError(
|
|
409,
|
|
`El usuario '${target.ssh_username}' no puede elevar privilegios: ` +
|
|
`${privileged.detail} Una instalación NUEVA en ${installPath} necesita ` +
|
|
'privilegios para registrar el servicio de sistema. Usa el arranque ' +
|
|
'"Servicio de usuario", que instala en el home y no los necesita.'
|
|
);
|
|
}
|
|
const inPlace = await probeInPlaceUpdate(sftp, installPath, target.ssh_username);
|
|
if (!inPlace.ok) {
|
|
throw new InstallError(
|
|
409,
|
|
`No se puede actualizar ${target.name} sin privilegios: ${inPlace.reason}`
|
|
);
|
|
}
|
|
inPlaceUpdate = true;
|
|
}
|
|
await appendInstallStep(
|
|
runId,
|
|
'precondiciones',
|
|
true,
|
|
`${systemEvidence}, privilegios=${privileged.label}` +
|
|
(inPlaceUpdate ? ', vía=actualización en sitio sin privilegios' : '')
|
|
);
|
|
|
|
// Antes de transferir: si es una actualización, confirmar que ahí VIVE una instalación.
|
|
if (request.mode === 'update') {
|
|
await assertExistingInstall(sftp, 'linux', installPath, reportedInstallPath);
|
|
}
|
|
|
|
for (const tool of ['tar', 'sha256sum']) {
|
|
const check = await execRemote(sftp, `command -v ${tool}`);
|
|
if (check.code !== 0) {
|
|
throw new InstallError(409, `El destino no tiene '${tool}', requerido para instalar`);
|
|
}
|
|
}
|
|
|
|
const remoteDir = `/tmp/cras-${release.version}-${runId}`;
|
|
const remotePkg = `${remoteDir}/${release.file_name}`;
|
|
const remoteEnv = `${remoteDir}/panel.env`;
|
|
|
|
try {
|
|
// Se verifica el código de salida: es el primer punto donde se nota que la cuenta no
|
|
// puede escribir en /tmp, típicamente por estar enjaulada (ChrootDirectory). Sin este
|
|
// chequeo el error aparecía después, como un fallo de transferencia sin causa clara.
|
|
const mk = await execRemote(
|
|
sftp,
|
|
`mkdir -p ${shQuote(remoteDir)} && chmod 700 ${shQuote(remoteDir)}`
|
|
);
|
|
if (mk.code !== 0) {
|
|
throw new InstallError(
|
|
409,
|
|
`No se pudo preparar ${remoteDir} en el destino (código ${mk.code}: ` +
|
|
`${truncate(mk.stderr || mk.stdout) || 'sin salida'}). ` +
|
|
'Causa probable: la cuenta SSH está enjaulada o no puede escribir ahí. ' +
|
|
'Puedes indicar otra carpeta con CRAS_REMOTE_STAGING_DIR.'
|
|
);
|
|
}
|
|
|
|
// --- Transferencia ---------------------------------------------------
|
|
const bytes = (await fs.stat(localPath)).size;
|
|
await appendInstallStep(runId, 'subir-artefacto', true, `${formatBytes(bytes)} → ${remotePkg}`);
|
|
await sftp.fastPut(localPath, remotePkg);
|
|
|
|
// --- Verificación de integridad en el destino ------------------------
|
|
const sum = await execRemote(sftp, `sha256sum ${shQuote(remotePkg)}`);
|
|
const remoteSha = (sum.stdout.split(/\s+/)[0] ?? '').toLowerCase();
|
|
if (sum.code !== 0 || remoteSha !== (release.sha256 ?? '').toLowerCase()) {
|
|
throw new InstallError(
|
|
502,
|
|
`El sha256 en el destino no coincide (esperado ${(release.sha256 ?? '').slice(0, 12)}…, ` +
|
|
`obtenido ${remoteSha.slice(0, 12) || 'nada'}…). La transferencia se corrompió.`
|
|
);
|
|
}
|
|
await appendInstallStep(runId, 'verificar-sha256', true, 'coincide con el publicado en Gitea');
|
|
|
|
// --- Siembra de configuración (archivo 0600, nunca por argv) ---------
|
|
// Se sube y luego se restringe: `put` no acepta el modo en su tipo de opciones, y
|
|
// dejar el token legible por todos aunque sea unos segundos no es aceptable, así
|
|
// que el chmod va inmediatamente después y antes de cualquier otro paso.
|
|
await sftp.put(Buffer.from(panelEnvContents(request, target.name), 'utf8'), remoteEnv);
|
|
await sftp.chmod(remoteEnv, 0o600);
|
|
await appendInstallStep(runId, 'sembrar-configuracion', true, 'panel.env 0600 subido');
|
|
|
|
// --- Extraer y ejecutar el instalador del paquete --------------------
|
|
const untar = await execRemote(
|
|
sftp,
|
|
`tar xzf ${shQuote(remotePkg)} -C ${shQuote(remoteDir)}`,
|
|
INSTALL_EXEC_TIMEOUT_MS
|
|
);
|
|
if (untar.code !== 0) {
|
|
throw new InstallError(502, `Falló la extracción: ${untar.stderr || untar.stdout}`);
|
|
}
|
|
|
|
const autostart = request.autostart ?? 'service';
|
|
// En la vía sin privilegios se usa --update-in-place, que no toca /etc ni systemctl:
|
|
// solo reemplaza el binario y señaliza al proceso para que Restart=always lo relevante.
|
|
const installerFlags = inPlaceUpdate
|
|
? ' --update-in-place'
|
|
: autostart === 'none'
|
|
? ''
|
|
: ` --${autostart}`;
|
|
// PREFIX es variable de entorno en install.sh, no un flag. La ruta no es secreta, así
|
|
// que pasarla por la línea de comandos está bien; el token sí va por archivo.
|
|
const installCmd =
|
|
`cd ${shQuote(`${remoteDir}/CloudRestoreAS`)} && ` +
|
|
`${privileged.prefix}env PREFIX=${shQuote(installPath)} ` +
|
|
`./install.sh${installerFlags} --panel-env-file ${shQuote(remoteEnv)}`;
|
|
|
|
await appendInstallStep(runId, 'ejecutar-instalador', true, `install.sh${installerFlags}`);
|
|
const install = await execRemote(sftp, installCmd, INSTALL_EXEC_TIMEOUT_MS);
|
|
// 75 = EX_TEMPFAIL, que install.sh reserva para "hay una restauración en curso". Es un
|
|
// conflicto de estado, no un fallo: hay que reintentar, no investigar. Con el 502 genérico
|
|
// parecía que la instalación se había roto, y el operador iría a buscar la causa equivocada.
|
|
if (install.code === 75) {
|
|
throw new InstallError(
|
|
409,
|
|
`${target.name} está restaurando un respaldo ahora mismo, así que no se actualizó ` +
|
|
'para no interrumpirlo —eso dejaría ese respaldo vetado y la base en ' +
|
|
'SINGLE_USER—. Reintenta cuando termine.'
|
|
);
|
|
}
|
|
if (install.code !== 0) {
|
|
throw new InstallError(
|
|
502,
|
|
`install.sh terminó con código ${install.code}: ${truncate(install.stderr || install.stdout)}`
|
|
);
|
|
}
|
|
|
|
// --- Verificación del despliegue -------------------------------------
|
|
await verifyLinuxDeployment(
|
|
sftp, runId, release, privileged, autostart, installPath, inPlaceUpdate
|
|
);
|
|
} finally {
|
|
// Limpieza siempre: el panel.env trae el token en claro. Si falla NO se silencia —
|
|
// significa que el token se quedó en el servidor y alguien tiene que ir a borrarlo.
|
|
await cleanupStaging(sftp, runId, remoteDir, `rm -rf ${shQuote(remoteDir)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Vías de elevación en Linux, de mejor a peor.
|
|
*
|
|
* `requiretty` se distingue de `ninguna` porque el remedio es el opuesto: con requiretty,
|
|
* agregar NOPASSWD no sirve de nada —sudo rechaza antes de mirar la política— y el consejo de
|
|
* "configura NOPASSWD" manda al operador a hacer algo inútil. Es un fallo de sudo, no de permisos.
|
|
*/
|
|
export type LinuxElevation = 'root' | 'sudo-sin-password' | 'requiretty' | 'ninguna';
|
|
|
|
export interface LinuxPrivilege {
|
|
/** Prefijo a poner delante de los comandos que requieren root. Vacío si no hay elevación. */
|
|
prefix: string;
|
|
label: string;
|
|
elevation: LinuxElevation;
|
|
/** Qué falta exactamente, en términos accionables. Vacío cuando sí hay elevación. */
|
|
detail: string;
|
|
}
|
|
|
|
/**
|
|
* `Defaults requiretty` en el sudoers del destino (aún común en derivados de RHEL) hace fallar
|
|
* cualquier sudo lanzado sobre un `exec` de SSH, que no tiene tty. El mensaje es estable desde
|
|
* hace dos décadas, así que la firma de stderr es fiable — y se clasifica sobre la SONDA, que es
|
|
* un comando controlado de una línea, no sobre la salida del instalador.
|
|
*/
|
|
function isRequireTty(stderr: string): boolean {
|
|
return /must have a tty|no tty present/i.test(stderr);
|
|
}
|
|
|
|
/**
|
|
* Sonda de elevación. La comparten el instalador y la pantalla de Verificar para que no puedan
|
|
* contradecirse: antes cada uno tenía su propia copia y ya diferían en la etiqueta.
|
|
*
|
|
* Nunca se le pasa el password a sudo. La vía sin privilegios no es un error aquí: el modo de
|
|
* instalación `user-service` no necesita ninguno, así que quien decide si falta algo es el
|
|
* llamador, no esta función.
|
|
*/
|
|
export async function probeLinuxElevation(sftp: SftpClient): Promise<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 -n true` da falso negativo cuando existe una regla NOPASSWD acotada a comandos
|
|
// concretos: `true` no está en ella, pero el comando real sí podría estarlo. Listar las
|
|
// reglas lo distingue, y de paso le dice al operador qué SÍ tiene concedido.
|
|
const list = await execRemote(sftp, 'sudo -n -l 2>&1 || true');
|
|
const scoped = list.code === 0 && /NOPASSWD:/i.test(list.stdout);
|
|
return {
|
|
prefix: '',
|
|
label: 'sin privilegios',
|
|
elevation: 'ninguna',
|
|
detail: scoped
|
|
? 'tiene reglas NOPASSWD pero acotadas a otros comandos: ' +
|
|
truncate(list.stdout.replace(/\s+/g, ' '))
|
|
: 'no es root y su sudo pide contraseña, que el panel nunca envía.'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Propiedades de un unit de systemd, leídas con `systemctl show`, que NO requiere privilegios
|
|
* (a diferencia de start/stop).
|
|
*
|
|
* Vive aquí y se comparte con la sonda de verificación porque ya hubo una divergencia por copiar
|
|
* esta lógica: la resolución de elevación existía duplicada en cras-verify y en cras-install, y
|
|
* las dos pantallas acabaron diciendo cosas distintas del mismo servidor.
|
|
*
|
|
* `User=` vacío significa root: es lo que systemd asume cuando el unit no lo declara. Tratarlo
|
|
* como cadena vacía haría creer que el servicio corre como un usuario sin nombre.
|
|
*/
|
|
export async function readUnitProps(
|
|
sftp: SftpClient,
|
|
unitName: string,
|
|
props: string[]
|
|
): Promise<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`
|
|
);
|
|
const state = active.stdout.trim() || active.stderr.trim();
|
|
if (state !== 'active') {
|
|
// En modo usuario el arranque puede haber quedado por cron (@reboot) en vez de systemd,
|
|
// cuando el destino no permite lingering. Ahí no hay unit que consultar y el proceso se
|
|
// comprueba directamente, que es lo que de verdad importa.
|
|
if (userMode || inPlaceUpdate) {
|
|
// El ancla ^ es obligatoria: sin ella el `sh -c` que corre este mismo pgrep lleva la
|
|
// ruta en su propia línea de comandos y haría match consigo mismo, reportando vivo un
|
|
// agente que no arrancó.
|
|
const alive = await execRemote(
|
|
sftp,
|
|
`pgrep -f ${shQuote(`^${installPath}/CloudRestoreAS`)} >/dev/null && echo si || echo no`
|
|
);
|
|
if (alive.stdout.trim() === 'si') {
|
|
await appendInstallStep(
|
|
runId,
|
|
'verificar-servicio',
|
|
true,
|
|
inPlaceUpdate
|
|
? `proceso vivo con el binario nuevo (unit intacto, estado: ${state})`
|
|
: 'proceso vivo (arranque por cron; systemd de usuario no disponible)'
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
throw new InstallError(
|
|
502,
|
|
`El servicio cloudrestoreas no quedó activo (estado: ${state || 'desconocido'}). ` +
|
|
(userMode
|
|
? 'Revisa `systemctl --user status cloudrestoreas` en el servidor.'
|
|
: 'Revisa journalctl -u cloudrestoreas en el servidor.')
|
|
);
|
|
}
|
|
await appendInstallStep(
|
|
runId,
|
|
'verificar-servicio',
|
|
true,
|
|
userMode ? 'cloudrestoreas active (systemd de usuario)' : 'cloudrestoreas active'
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Windows
|
|
// ============================================================================
|
|
|
|
async function installWindows(
|
|
sftp: SftpClient,
|
|
runId: number,
|
|
release: CrasRelease,
|
|
localPath: string,
|
|
request: InstallRequest,
|
|
target: RestoreTargetSsh,
|
|
installPath: string,
|
|
reportedInstallPath: string | null
|
|
): Promise<void> {
|
|
// --- Precondiciones ------------------------------------------------------
|
|
const systemEvidence = await assertSystemMatches(sftp, 'windows');
|
|
|
|
const admin = await execRemote(
|
|
sftp,
|
|
psEncoded(
|
|
'$p=[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent();' +
|
|
'if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){"admin"}else{"limitado"}'
|
|
)
|
|
);
|
|
const autostart = request.autostart ?? 'service';
|
|
if (autostart === 'service' && admin.stdout.trim() !== 'admin') {
|
|
throw new InstallError(
|
|
409,
|
|
'El usuario SSH no es Administrador en el destino. La tarea programada ONSTART corre ' +
|
|
'como SYSTEM y requiere elevación; usa una cuenta administradora o instala sin servicio.'
|
|
);
|
|
}
|
|
await appendInstallStep(
|
|
runId,
|
|
'precondiciones',
|
|
true,
|
|
`${systemEvidence}, privilegios=${admin.stdout.trim()}`
|
|
);
|
|
|
|
if (request.mode === 'update') {
|
|
await assertExistingInstall(sftp, 'windows', installPath, reportedInstallPath);
|
|
}
|
|
|
|
// Se usa una carpeta bajo el TEMP del usuario SSH, no C:\Windows\Temp.
|
|
const remoteDirPs = `$env:TEMP\\cras-${release.version}-${runId}`;
|
|
const tempResolved = await execRemote(sftp, psEncoded(`Write-Output "${remoteDirPs}"`));
|
|
const remoteDir = tempResolved.stdout.trim();
|
|
if (!remoteDir) {
|
|
throw new InstallError(502, 'No se pudo resolver la carpeta temporal del destino');
|
|
}
|
|
const remotePkg = `${remoteDir}\\${release.file_name}`;
|
|
const remoteEnv = `${remoteDir}\\panel.env`;
|
|
|
|
try {
|
|
const mk = await execRemote(
|
|
sftp,
|
|
psEncoded(`New-Item -ItemType Directory -Path '${remoteDir}' -Force | Out-Null`)
|
|
);
|
|
if (mk.code !== 0) {
|
|
throw new InstallError(
|
|
502,
|
|
`No se pudo crear ${remoteDir}: ${truncate(mk.stderr || mk.stdout) || 'sin salida'}`
|
|
);
|
|
}
|
|
|
|
// --- Transferencia ---------------------------------------------------
|
|
const bytes = (await fs.stat(localPath)).size;
|
|
await appendInstallStep(runId, 'subir-artefacto', true, `${formatBytes(bytes)} → ${remotePkg}`);
|
|
await sftp.fastPut(localPath, remotePkg.replace(/\\/g, '/'));
|
|
|
|
// --- Verificación de integridad en el destino ------------------------
|
|
const sum = await execRemote(
|
|
sftp,
|
|
psEncoded(`(Get-FileHash -Algorithm SHA256 -LiteralPath '${remotePkg}').Hash`)
|
|
);
|
|
const remoteSha = sum.stdout.trim().toLowerCase();
|
|
if (sum.code !== 0 || remoteSha !== (release.sha256 ?? '').toLowerCase()) {
|
|
throw new InstallError(
|
|
502,
|
|
`El sha256 en el destino no coincide (esperado ${(release.sha256 ?? '').slice(0, 12)}…, ` +
|
|
`obtenido ${remoteSha.slice(0, 12) || 'nada'}…). La transferencia se corrompió.`
|
|
);
|
|
}
|
|
await appendInstallStep(runId, 'verificar-sha256', true, 'coincide con el publicado en Gitea');
|
|
|
|
// --- Siembra de configuración ----------------------------------------
|
|
await sftp.put(
|
|
Buffer.from(panelEnvContents(request, target.name), 'utf8'),
|
|
remoteEnv.replace(/\\/g, '/')
|
|
);
|
|
// Los permisos POSIX no aplican en NTFS: se restringe el ACL a Administradores y
|
|
// SYSTEM para que el token no quede legible por cualquier usuario del servidor.
|
|
// Se restringe el DIRECTORIO además del archivo: New-Item hereda el ACL del padre, y
|
|
// si el $env:TEMP de la cuenta resuelve a C:\Windows\Temp ese padre es accesible por
|
|
// todos. Y se verifica el código de salida: un icacls que falla en silencio dejaría el
|
|
// token legible, que es justo lo que se está tratando de evitar.
|
|
const acl = await execRemote(
|
|
sftp,
|
|
psEncoded(
|
|
`icacls '${remoteDir}' /inheritance:r ` +
|
|
'/grant:r "BUILTIN\\Administrators:(F)" /grant:r "NT AUTHORITY\\SYSTEM:(F)" | Out-Null; ' +
|
|
`icacls '${remoteEnv}' /inheritance:r ` +
|
|
'/grant:r "BUILTIN\\Administrators:(F)" /grant:r "NT AUTHORITY\\SYSTEM:(F)" | Out-Null'
|
|
)
|
|
);
|
|
if (acl.code !== 0) {
|
|
throw new InstallError(
|
|
502,
|
|
'No se pudo restringir el acceso al archivo con el token del panel ' +
|
|
`(icacls código ${acl.code}: ${truncate(acl.stderr || acl.stdout) || 'sin salida'}). ` +
|
|
'Se aborta para no dejarlo legible por otros usuarios del servidor.'
|
|
);
|
|
}
|
|
await appendInstallStep(runId, 'sembrar-configuracion', true, 'panel.env con ACL restringido');
|
|
|
|
// --- Extraer y ejecutar el instalador --------------------------------
|
|
const expand = await execRemote(
|
|
sftp,
|
|
psEncoded(
|
|
`Expand-Archive -LiteralPath '${remotePkg}' -DestinationPath '${remoteDir}' -Force`
|
|
),
|
|
INSTALL_EXEC_TIMEOUT_MS
|
|
);
|
|
if (expand.code !== 0) {
|
|
throw new InstallError(
|
|
502,
|
|
`Falló Expand-Archive: ${truncate(expand.stderr || expand.stdout) || 'sin salida'}`
|
|
);
|
|
}
|
|
|
|
const flag = autostart === 'none' ? '' : autostart === 'service' ? ' -Service' : ' -Desktop';
|
|
await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`);
|
|
const install = await execRemote(
|
|
sftp,
|
|
psEncoded(
|
|
`& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` +
|
|
`-Prefix '${installPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE`
|
|
),
|
|
INSTALL_EXEC_TIMEOUT_MS
|
|
);
|
|
if (install.code !== 0) {
|
|
throw new InstallError(
|
|
502,
|
|
`install.ps1 terminó con código ${install.code}: ${truncate(install.stderr || install.stdout)}`
|
|
);
|
|
}
|
|
|
|
await verifyWindowsDeployment(sftp, runId, release, autostart, installPath);
|
|
} finally {
|
|
await cleanupStaging(
|
|
sftp,
|
|
runId,
|
|
remoteDir,
|
|
psEncoded(
|
|
`Remove-Item -LiteralPath '${remoteDir}' -Recurse -Force -ErrorAction Stop`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
async function verifyWindowsDeployment(
|
|
sftp: SftpClient,
|
|
runId: number,
|
|
release: CrasRelease,
|
|
autostart: AutostartMode,
|
|
prefix: string
|
|
): Promise<void> {
|
|
const stamp = await execRemote(
|
|
sftp,
|
|
psEncoded(
|
|
`if (Test-Path '${prefix}\\config\\.version') ` +
|
|
`{ Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw }`
|
|
)
|
|
);
|
|
const deployed = stamp.stdout.trim();
|
|
if (deployed && deployed !== release.version) {
|
|
throw new InstallError(
|
|
502,
|
|
`El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}`
|
|
);
|
|
}
|
|
await appendInstallStep(
|
|
runId,
|
|
'verificar-version',
|
|
true,
|
|
deployed ? `config\\.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)'
|
|
);
|
|
|
|
if (autostart !== 'service') return;
|
|
|
|
const task = await execRemote(
|
|
sftp,
|
|
psEncoded(
|
|
"(Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue).State"
|
|
)
|
|
);
|
|
const state = task.stdout.trim();
|
|
if (!state) {
|
|
throw new InstallError(
|
|
502,
|
|
'La tarea programada CloudRestoreAS no quedó registrada en el destino.'
|
|
);
|
|
}
|
|
await appendInstallStep(runId, 'verificar-servicio', true, `tarea CloudRestoreAS: ${state}`);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Utilidades
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Borra el directorio de staging del destino. **No silencia el fallo**: ese directorio
|
|
* contiene `panel.env` con el token del panel en claro, así que si no se pudo borrar hay que
|
|
* dejarlo asentado —en la bitácora y en el log— con la ruta exacta, para que alguien vaya a
|
|
* limpiarlo. Antes se descartaba con `.catch(() => undefined)` y el token quedaba ahí sin que
|
|
* nadie se enterara.
|
|
*
|
|
* Corre dentro de un `finally`, así que nunca lanza: eso enmascararía el error real de la
|
|
* instalación.
|
|
*/
|
|
async function cleanupStaging(
|
|
sftp: SftpClient,
|
|
runId: number,
|
|
remoteDir: string,
|
|
command: string
|
|
): Promise<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;
|
|
}
|