feature/interfaz-binarios (#19)
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
Reviewed-on: #19 Co-authored-by: hreyes <hreyes@aduanasoft.com.mx> Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
This commit is contained in:
929
src/lib/server/cras-install.ts
Normal file
929
src/lib/server/cras-install.ts
Normal file
@@ -0,0 +1,929 @@
|
||||
/**
|
||||
* 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.
|
||||
* - **Se exige root o `sudo -n`** (sudo sin password). Nunca se le pasa el password a sudo.
|
||||
* Si no hay privilegios se aborta ANTES de subir 270 MB.
|
||||
* - **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 { 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?: 'service' | 'desktop' | 'none';
|
||||
}
|
||||
|
||||
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 reportada por el agente; 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. ') +
|
||||
'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');
|
||||
|
||||
const privileged = await resolveLinuxPrivilege(sftp, target.ssh_username);
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'precondiciones',
|
||||
true,
|
||||
`${systemEvidence}, privilegios=${privileged.label}`
|
||||
);
|
||||
|
||||
// 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';
|
||||
const installerFlags = 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);
|
||||
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);
|
||||
} 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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface LinuxPrivilege {
|
||||
/** Prefijo a poner delante de los comandos que requieren root. */
|
||||
prefix: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve cómo obtener privilegios: root directo o `sudo -n` (sin password).
|
||||
*
|
||||
* Nunca se le pasa el password a sudo por stdin. Si no hay ninguna de las dos vías se
|
||||
* aborta aquí, antes de transferir el artefacto: fallar tras subir 270 MB es desperdicio y
|
||||
* deja basura en /tmp del destino.
|
||||
*/
|
||||
async function resolveLinuxPrivilege(
|
||||
sftp: SftpClient,
|
||||
sshUsername: string
|
||||
): Promise<LinuxPrivilege> {
|
||||
const id = await execRemote(sftp, 'id -u');
|
||||
if (id.code === 0 && id.stdout.trim() === '0') {
|
||||
return { prefix: '', label: 'root' };
|
||||
}
|
||||
const sudo = await execRemote(sftp, 'sudo -n true');
|
||||
if (sudo.code === 0) {
|
||||
return { prefix: 'sudo -n ', label: 'sudo sin password' };
|
||||
}
|
||||
throw new InstallError(
|
||||
409,
|
||||
`El usuario '${sshUsername}' no es root y no tiene sudo sin password. ` +
|
||||
'Configura NOPASSWD para ese usuario o usa una cuenta root: el instalador necesita ' +
|
||||
'privilegios para el servicio systemd y, por seguridad, no se le pasa la contraseña a sudo.'
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyLinuxDeployment(
|
||||
sftp: SftpClient,
|
||||
runId: number,
|
||||
release: CrasRelease,
|
||||
privileged: LinuxPrivilege,
|
||||
autostart: 'service' | 'desktop' | 'none',
|
||||
prefix: string
|
||||
): 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).
|
||||
const stamp = await execRemote(sftp, `cat ${shQuote(`${prefix}/config/.version`)} 2>/dev/null`);
|
||||
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 active = await execRemote(sftp, `${privileged.prefix}systemctl is-active cloudrestoreas`);
|
||||
const state = active.stdout.trim() || active.stderr.trim();
|
||||
if (state !== 'active') {
|
||||
throw new InstallError(
|
||||
502,
|
||||
`El servicio cloudrestoreas no quedó activo (estado: ${state || 'desconocido'}). ` +
|
||||
'Revisa journalctl -u cloudrestoreas en el servidor.'
|
||||
);
|
||||
}
|
||||
await appendInstallStep(runId, 'verificar-servicio', true, '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: 'service' | 'desktop' | 'none',
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user