feature/interfaz-binarios (#19)
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:
2026-07-30 13:52:45 +00:00
committed by acazares
parent a9a1dcb9ae
commit 14b611c581
41 changed files with 10355 additions and 29 deletions

View File

@@ -10,7 +10,10 @@ export function newTraceId(): string {
}
export function errorJson(
code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 503,
// 502 para fallos de un servicio del que dependemos (p.ej. Gitea al sincronizar
// versiones de CRAS): distinguirlo de 503 le dice al operador que el panel está bien y
// el problema está aguas arriba.
code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 502 | 503,
message: string,
traceId: string
) {

View File

@@ -1,8 +1,23 @@
/**
* Pruebas de matchNodeRowFromBackupStem (resolución de nodo desde nombre de archivo).
* Pruebas de matchNodeRowFromBackupStem (resolución de nodo desde nombre de archivo) y de
* deleteRestoreJobLogs (borrado definitivo de restores fallidos).
*/
import { describe, it, expect } from 'vitest';
import { matchNodeRowFromBackupStem } from './controldesk-pg';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// vi.mock se iza al tope del archivo, así que las factories no pueden capturar variables
// declaradas después: se usa vi.hoisted, igual que en api/alerts/send/server.test.ts.
const { clientQuery, clientRelease, connectMock } = vi.hoisted(() => {
const clientQuery = vi.fn();
const clientRelease = vi.fn();
return {
clientQuery,
clientRelease,
connectMock: vi.fn(async () => ({ query: clientQuery, release: clientRelease }))
};
});
vi.mock('$lib/server/db', () => ({ pgPool: { query: vi.fn(), connect: connectMock } }));
import { matchNodeRowFromBackupStem, deleteRestoreJobLogs } from './controldesk-pg';
const NODES = [
{
@@ -30,3 +45,98 @@ describe('matchNodeRowFromBackupStem', () => {
expect(matchNodeRowFromBackupStem('DESCONOCIDO', NODES)).toBeNull();
});
});
/**
* deleteRestoreJobLogs BORRA definitivamente. Lo que se prueba aquí es lo que hace peligroso
* un DELETE: que vaya en transacción, que cuente antes, que no toque restores exitosos, y que
* el número que reporta sea el de filas realmente borradas y no el de IDs recibidos.
*/
describe('deleteRestoreJobLogs', () => {
/** Simula el par COUNT previo + DELETE dentro de la transacción. */
function stubTx(eligible: number, notFailed: number, deleted: number) {
clientQuery.mockReset();
clientQuery.mockImplementation(async (sql: string) => {
if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(sql)) return { rows: [], rowCount: 0 };
if (/^\s*SELECT/i.test(sql)) {
return { rows: [{ eligible: String(eligible), not_failed: String(notFailed) }] };
}
if (/^\s*DELETE/i.test(sql)) return { rows: [], rowCount: deleted };
throw new Error(`SQL inesperado: ${sql}`);
});
}
beforeEach(() => {
clientQuery.mockReset();
clientRelease.mockReset();
connectMock.mockClear();
});
it('borra en transacción, contando antes del DELETE', async () => {
stubTx(2, 0, 2);
const r = await deleteRestoreJobLogs([10, 11]);
expect(r).toEqual({ requested: 2, eligible: 2, deleted: 2, skippedNotFailed: 0 });
const sqls = clientQuery.mock.calls.map((c) => String(c[0]).trim());
expect(sqls[0]).toMatch(/^BEGIN/);
expect(sqls.at(-1)).toMatch(/^COMMIT/);
// El COUNT tiene que ir ANTES del DELETE, o no sirve para reportar nada.
const iCount = sqls.findIndex((s) => /^SELECT/i.test(s));
const iDelete = sqls.findIndex((s) => /^DELETE/i.test(s));
expect(iCount).toBeGreaterThan(0);
expect(iDelete).toBeGreaterThan(iCount);
expect(clientRelease).toHaveBeenCalledOnce();
});
it("solo borra filas con status 'failed'", async () => {
stubTx(1, 0, 1);
await deleteRestoreJobLogs([7]);
const del = clientQuery.mock.calls.map((c) => String(c[0])).find((s) => /DELETE/i.test(s));
// La restricción vive en el servidor, no solo en la UI: los restores exitosos son el
// registro de auditoría del que sale "última restauración exitosa".
expect(del).toMatch(/status\s*=\s*'failed'/);
});
it('reporta los que existen pero no son fallidos, sin borrarlos', async () => {
stubTx(0, 3, 0);
const r = await deleteRestoreJobLogs([1, 2, 3]);
expect(r.deleted).toBe(0);
expect(r.skippedNotFailed).toBe(3);
});
it('devuelve las filas REALMENTE borradas, no la cantidad de IDs pedidos', async () => {
// Caso real: la pestaña llevaba rato abierta y otro operador ya borró dos de los tres.
stubTx(1, 0, 1);
const r = await deleteRestoreJobLogs([4, 5, 6]);
expect(r.requested).toBe(3);
expect(r.deleted).toBe(1);
});
it('sanea los IDs: descarta 0, negativos y no enteros, y deduplica', async () => {
stubTx(1, 0, 1);
await deleteRestoreJobLogs([5, 5, 0, -3, 2.7, Number.NaN, Infinity] as number[]);
const call = clientQuery.mock.calls.find((c) => /SELECT/i.test(String(c[0])));
expect(call?.[1]).toEqual([[5]]);
});
it('no abre transacción si no queda ningún ID válido', async () => {
const r = await deleteRestoreJobLogs([0, -1, Number.NaN]);
expect(r).toEqual({ requested: 0, eligible: 0, deleted: 0, skippedNotFailed: 0 });
expect(connectMock).not.toHaveBeenCalled();
});
it('hace ROLLBACK y libera el cliente si el DELETE falla', async () => {
clientQuery.mockReset();
clientQuery.mockImplementation(async (sql: string) => {
if (/^\s*DELETE/i.test(sql)) throw new Error('deadlock detectado');
if (/^\s*SELECT/i.test(sql)) return { rows: [{ eligible: '1', not_failed: '0' }] };
return { rows: [], rowCount: 0 };
});
await expect(deleteRestoreJobLogs([9])).rejects.toThrow('deadlock detectado');
const sqls = clientQuery.mock.calls.map((c) => String(c[0]).trim());
expect(sqls.some((s) => /^ROLLBACK/i.test(s))).toBe(true);
expect(sqls.some((s) => /^COMMIT/i.test(s))).toBe(false);
expect(clientRelease).toHaveBeenCalledOnce();
});
});

View File

@@ -69,6 +69,20 @@ async function ensureCloudRestoreStatusTable(): Promise<void> {
await pgPool.query(
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS processed_folder VARCHAR(500)`
);
// Plataforma y arquitectura del build instalado (migración e2f3a4b5c6d7 de a24c). Son la
// fuente autoritativa para decidir qué artefacto de CRAS le toca a este servidor; un
// agente anterior a 1.1.0 no las reporta y quedan NULL.
await pgPool.query(
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS platform VARCHAR(20)`
);
await pgPool.query(
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS arch VARCHAR(20)`
);
// Ruta donde vive el ejecutable (APP_DIR del agente). Es un espacio de rutas distinto de
// input_folder: ahí vive config/.env con las rutas de trabajo que el operador personalizó.
await pgPool.query(
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS install_path VARCHAR(500)`
);
}
/** Añade columnas de tamaño/ruta relativa a restore_job_logs si faltan (idempotente). */
@@ -624,6 +638,48 @@ export async function getRestoreTargetById(id: number): Promise<RestoreTarget |
return (r.rows[0] as RestoreTarget) ?? null;
}
export interface RestoreTargetSsh {
id: number;
name: string;
ssh_host: string;
ssh_port: number;
ssh_username: string;
ssh_password: string;
os: string | null;
}
/**
* Credenciales SSH de un servidor de restauración, descifradas.
*
* Uso exclusivo del instalador remoto de CRAS (cras-install.ts). Devuelve null si el
* servidor no existe o si le falta algún dato de conexión: es preferible fallar con un
* mensaje claro antes de tocar nada, que intentar conectar con credenciales incompletas.
*/
export async function getRestoreTargetSsh(id: number): Promise<RestoreTargetSsh | null> {
await ensureRestoreTargetsSchema();
const r = await pgPool.query(
`SELECT id, name, ssh_host, ssh_port, ssh_username, ssh_password_encrypted, os
FROM ${qRestoreTargets()} WHERE id = $1`,
[id]
);
const row = r.rows[0];
if (!row) return null;
const host = (row.ssh_host ?? '').trim();
const username = (row.ssh_username ?? '').trim();
if (!host || !username || !row.ssh_password_encrypted) return null;
return {
id: row.id,
name: row.name,
ssh_host: host,
ssh_port: Number(row.ssh_port) > 0 ? Number(row.ssh_port) : 22,
ssh_username: username,
ssh_password: decryptSecret(row.ssh_password_encrypted),
os: row.os ?? null
};
}
/**
* Servidor de restauración por id con la contraseña SQL descifrada. Uso exclusivo del servidor
* (conectar a SQL Server para depurar bases duplicadas); NUNCA se expone al cliente.
@@ -980,6 +1036,9 @@ export interface CloudRestoreStatus {
processed_folder: string | null;
host_name: string | null;
app_version: string | null;
platform: string | null;
arch: string | null;
install_path: string | null;
reported_at: Date;
}
@@ -989,7 +1048,8 @@ export async function listCloudRestoreStatuses(): Promise<CloudRestoreStatus[]>
await ensureCloudRestoreStatusTable();
const r = await pgPool.query(
`
SELECT id, instance_key, input_folder, processed_folder, host_name, app_version, reported_at
SELECT id, instance_key, input_folder, processed_folder, host_name, app_version,
platform, arch, install_path, reported_at
FROM ${qCloudRestoreStatus()}
ORDER BY instance_key
`
@@ -1013,6 +1073,9 @@ export async function upsertCloudRestoreStatus(row: {
processedFolder?: string | null;
hostName: string | null;
appVersion: string | null;
platform?: string | null;
arch?: string | null;
installPath?: string | null;
instanceKey?: string;
}): Promise<void> {
await ensureCloudRestoreStatusTable();
@@ -1020,16 +1083,31 @@ export async function upsertCloudRestoreStatus(row: {
await pgPool.query(
`
INSERT INTO ${qCloudRestoreStatus()} (
instance_key, input_folder, processed_folder, host_name, app_version, reported_at
) VALUES ($1, $2, $3, $4, $5, now())
instance_key, input_folder, processed_folder, host_name, app_version,
platform, arch, install_path, reported_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (instance_key) DO UPDATE SET
input_folder = EXCLUDED.input_folder,
processed_folder = EXCLUDED.processed_folder,
host_name = EXCLUDED.host_name,
app_version = EXCLUDED.app_version,
-- COALESCE: un agente anterior a 1.1.0 no reporta platform/arch, y no debe
-- borrar lo que ya se sabía de ese servidor por un reporte incompleto.
platform = COALESCE(EXCLUDED.platform, ${qCloudRestoreStatus()}.platform),
arch = COALESCE(EXCLUDED.arch, ${qCloudRestoreStatus()}.arch),
install_path = COALESCE(EXCLUDED.install_path, ${qCloudRestoreStatus()}.install_path),
reported_at = now()
`,
[key, row.inputFolder, row.processedFolder ?? null, row.hostName, row.appVersion]
[
key,
row.inputFolder,
row.processedFolder ?? null,
row.hostName,
row.appVersion,
row.platform ?? null,
row.arch ?? null,
row.installPath ?? null
]
);
}
@@ -1281,7 +1359,8 @@ export async function listFailedRestoreJobLogs(limit = 100): Promise<FailedResto
`
SELECT
jl.id, jl.restore_target_id, rt.name AS server_name,
jl.filename, jl.db_name, jl.error_message, jl.rel_path, jl.size_bytes, jl.restored_at
jl.filename, jl.db_name, jl.error_message, jl.rel_path, jl.size_bytes,
jl.restored_at
FROM ${qRestoreJobLogs()} jl
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = jl.restore_target_id
WHERE jl.status = 'failed'
@@ -1348,6 +1427,96 @@ export async function listRestoredRestoreJobLogs(limit = 200): Promise<RestoredR
}
}
/** Resultado de un borrado de bitácora: lo pedido, lo elegible y lo que realmente se fue. */
export interface DeleteRestoreLogsResult {
/** IDs válidos recibidos (tras sanear). */
requested: number;
/** De esos, cuántos existían Y eran `failed` — el COUNT previo al DELETE. */
eligible: number;
/** Filas efectivamente borradas. Debe coincidir con `eligible`. */
deleted: number;
/** Cuántos se ignoraron por no ser fallidos (existen pero son completed/forwarded). */
skippedNotFailed: number;
}
/**
* BORRA registros de la bitácora de forma definitiva. No hay deshacer.
*
* Solo borra filas con `status = 'failed'`, y esa restricción vive aquí y no solo en la UI a
* propósito. Dos razones:
*
* 1. "Respaldos Restaurados" es el registro de auditoría de lo que sí se restauró, y de ahí
* sale `MAX(restored_at) FILTER (WHERE status='completed')` en listRestoreJobLogSummaries,
* o sea la "última restauración exitosa" que /servidores-restauracion usa como señal de
* salud. Borrar ahí no limpiaría ruido: haría retroceder un indicador.
* 2. Acota el daño de un ID equivocado en el POST a filas que de todos modos son residuo.
*
* Consecuencia aceptada y documentada: el contador de FALLAS de los últimos 30 días en
* /servidores-restauracion sí baja, porque cuenta esta misma tabla. Es el efecto buscado.
*
* Recibe IDs explícitos y no un filtro: lo borrado es exactamente lo que el operador vio en
* pantalla, sin duplicar la lógica del buscador del cliente en el servidor.
*
* Va en transacción con COUNT previo (estándar de Aduanasoft para destructivas): así se puede
* reportar por qué `deleted` no coincide con lo pedido —IDs ya borrados por otro operador, o
* filas que no son fallidas— en lugar de devolver un número sin explicación.
*/
export async function deleteRestoreJobLogs(ids: number[]): Promise<DeleteRestoreLogsResult> {
const clean = sanitizeIds(ids);
const empty: DeleteRestoreLogsResult = {
requested: 0,
eligible: 0,
deleted: 0,
skippedNotFailed: 0
};
if (clean.length === 0) return empty;
const client = await pgPool.connect();
try {
await client.query('BEGIN');
// COUNT previo dentro de la misma transacción: cuenta lo elegible y, aparte, lo que
// existe pero NO es fallido, para distinguir "ya no estaba" de "no se permite".
const pre = await client.query(
`SELECT
count(*) FILTER (WHERE status = 'failed') AS eligible,
count(*) FILTER (WHERE status <> 'failed') AS not_failed
FROM ${qRestoreJobLogs()}
WHERE id = ANY($1::int[])`,
[clean]
);
const eligible = Number(pre.rows[0]?.eligible ?? 0);
const skippedNotFailed = Number(pre.rows[0]?.not_failed ?? 0);
const del = await client.query(
`DELETE FROM ${qRestoreJobLogs()}
WHERE id = ANY($1::int[]) AND status = 'failed'`,
[clean]
);
await client.query('COMMIT');
return {
requested: clean.length,
eligible,
deleted: del.rowCount ?? 0,
skippedNotFailed
};
} catch (e) {
await client.query('ROLLBACK');
if (isPgUndefinedTable(e)) return empty; // la tabla la crea a24c
throw e;
} finally {
client.release();
}
}
/** Enteros positivos únicos. Mismo criterio que assignNodesToRestoreTarget. */
function sanitizeIds(ids: number[]): number[] {
return Array.from(
new Set((ids ?? []).filter((n) => Number.isInteger(n) && n > 0))
);
}
/** Carpetas de un restaurador para descarga por filesystem (input/processed reportados). */
export interface RestoreTargetDownload {
id: number;

View File

@@ -0,0 +1,273 @@
/**
* Caché local de artefactos de CloudRestoreAS.
*
* Lo crítico aquí es que un artefacto corrupto NUNCA llegue a tener nombre definitivo: el
* rename solo ocurre después de que el sha256 cuadra. Si eso falla, el panel empujaría un
* binario roto a un servidor de producción. También se prueba el anti-traversal, porque
* version y fileName se usan para construir rutas.
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { env } from '$env/dynamic/private';
import {
ArtifactError,
artifactPath,
cacheDir,
cacheUsage,
ensureCached,
isCached,
isSafeFileName,
isSafeVersion,
pruneCache,
removeCached,
selectPrunableVersions
} from './cras-artifacts';
const originalFetch = globalThis.fetch;
let tmpRoot: string;
function sha256Of(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
/** Stub de la descarga de Gitea: responde el contenido indicado como stream. */
function stubDownload(content: string, status = 200) {
globalThis.fetch = vi.fn(async () => {
if (status !== 200) return new Response('', { status });
return new Response(content, {
status: 200,
headers: { 'content-length': String(Buffer.byteLength(content)) }
});
}) as unknown as typeof fetch;
}
beforeEach(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cras-cache-'));
env.CRAS_RELEASES_DIR = tmpRoot;
env.GITEA_TOKEN = 'token-de-prueba';
env.GITEA_BASE_URL = 'https://git.ejemplo.test';
});
afterEach(async () => {
globalThis.fetch = originalFetch;
await fs.rm(tmpRoot, { recursive: true, force: true });
for (const key of ['CRAS_RELEASES_DIR', 'GITEA_TOKEN', 'GITEA_BASE_URL', 'CRAS_CACHE_KEEP_VERSIONS']) {
delete env[key];
}
vi.restoreAllMocks();
});
describe('validación de nombres', () => {
it('acepta versiones y nombres de artefacto reales', () => {
expect(isSafeVersion('1.1.0')).toBe(true);
expect(isSafeVersion('26.7.1.4')).toBe(true);
expect(isSafeFileName('CloudRestoreAS-1.1.0-linux-x86_64.tar.gz')).toBe(true);
expect(isSafeFileName('SHA256SUMS')).toBe(true);
});
it('rechaza intentos de salir de la caché', () => {
for (const version of ['..', '../etc', '1.1.0/../..', '', 'latest']) {
expect(isSafeVersion(version), version).toBe(false);
}
for (const file of ['../evil', '/etc/passwd', 'a/b.zip', '..', '.oculto', 'con espacio.zip']) {
expect(isSafeFileName(file), file).toBe(false);
}
});
it('artifactPath lanza ArtifactError ante entradas inseguras', () => {
expect(() => artifactPath('../etc', 'x.zip')).toThrow(ArtifactError);
expect(() => artifactPath('1.1.0', '../../evil')).toThrow(ArtifactError);
});
it('artifactPath resuelve dentro de la caché', () => {
const p = artifactPath('1.1.0', 'a.tar.gz');
expect(p).toBe(path.join(cacheDir(), '1.1.0', 'a.tar.gz'));
expect(p.startsWith(path.resolve(cacheDir()) + path.sep)).toBe(true);
});
});
describe('ensureCached', () => {
const FILE = 'CloudRestoreAS-1.1.0-linux-x86_64.tar.gz';
const CONTENT = 'contenido-del-artefacto';
it('descarga, verifica el sha256 y deja el archivo definitivo', async () => {
stubDownload(CONTENT);
const result = await ensureCached('1.1.0', FILE, sha256Of(CONTENT), Buffer.byteLength(CONTENT));
expect(result.downloaded).toBe(true);
expect(result.size).toBe(Buffer.byteLength(CONTENT));
expect(await fs.readFile(result.path, 'utf8')).toBe(CONTENT);
expect(await isCached('1.1.0', FILE)).toBe(true);
});
it('no re-descarga si ya está en caché', async () => {
stubDownload(CONTENT);
await ensureCached('1.1.0', FILE, sha256Of(CONTENT));
const spy = vi.fn();
globalThis.fetch = spy as unknown as typeof fetch;
const second = await ensureCached('1.1.0', FILE, sha256Of(CONTENT));
expect(second.downloaded).toBe(false);
expect(spy).not.toHaveBeenCalled();
});
it('con sha256 distinto NO deja el archivo definitivo y borra el temporal', async () => {
// Este es el caso que evita empujar un binario corrupto a producción.
stubDownload(CONTENT);
await expect(ensureCached('1.1.0', FILE, sha256Of('otro-contenido'))).rejects.toThrow(
/sha256/i
);
expect(await isCached('1.1.0', FILE)).toBe(false);
const leftovers = await fs.readdir(path.join(tmpRoot, '1.1.0')).catch(() => []);
expect(leftovers.filter((f) => f.includes('.part'))).toEqual([]);
});
it('con tamaño distinto al declarado también aborta', async () => {
stubDownload(CONTENT);
await expect(
ensureCached('1.1.0', FILE, sha256Of(CONTENT), Buffer.byteLength(CONTENT) + 100)
).rejects.toThrow(/tama/i);
expect(await isCached('1.1.0', FILE)).toBe(false);
});
it('sin sha256 registrado se niega a descargar 270 MB a ciegas', async () => {
const spy = vi.fn();
globalThis.fetch = spy as unknown as typeof fetch;
await expect(ensureCached('1.1.0', FILE, null)).rejects.toMatchObject({ status: 409 });
expect(spy).not.toHaveBeenCalled();
});
it('con un sha256 mal formado también se niega', async () => {
const spy = vi.fn();
globalThis.fetch = spy as unknown as typeof fetch;
await expect(ensureCached('1.1.0', FILE, 'abc123')).rejects.toMatchObject({ status: 409 });
expect(spy).not.toHaveBeenCalled();
});
it('un fallo de descarga limpia el temporal', async () => {
stubDownload('', 500);
await expect(ensureCached('1.1.0', FILE, sha256Of(CONTENT))).rejects.toThrow();
const leftovers = await fs.readdir(path.join(tmpRoot, '1.1.0')).catch(() => []);
expect(leftovers.filter((f) => f.includes('.part'))).toEqual([]);
});
});
describe('cacheUsage y removeCached', () => {
it('mide el uso por versión e ignora los .part', async () => {
await fs.mkdir(path.join(tmpRoot, '1.1.0'), { recursive: true });
await fs.writeFile(path.join(tmpRoot, '1.1.0', 'a.tar.gz'), 'x'.repeat(100));
await fs.writeFile(path.join(tmpRoot, '1.1.0', 'b.zip'), 'y'.repeat(50));
// Un temporal de una descarga en curso no debe contar como espacio consumido útil.
await fs.writeFile(path.join(tmpRoot, '1.1.0', 'c.zip.123.part'), 'z'.repeat(999));
const usage = await cacheUsage();
expect(usage.total_bytes).toBe(150);
expect(usage.versions).toEqual([{ version: '1.1.0', bytes: 150, files: 2 }]);
});
it('ignora carpetas que no son versiones válidas', async () => {
await fs.mkdir(path.join(tmpRoot, 'basura'), { recursive: true });
await fs.writeFile(path.join(tmpRoot, 'basura', 'x'), 'x'.repeat(10));
expect((await cacheUsage()).total_bytes).toBe(0);
});
it('devuelve vacío si la caché no existe todavía', async () => {
env.CRAS_RELEASES_DIR = path.join(tmpRoot, 'no-existe');
expect(await cacheUsage()).toEqual({ total_bytes: 0, versions: [] });
});
it('removeCached borra el archivo', async () => {
await fs.mkdir(path.join(tmpRoot, '1.1.0'), { recursive: true });
await fs.writeFile(path.join(tmpRoot, '1.1.0', 'a.tar.gz'), 'x');
await removeCached('1.1.0', 'a.tar.gz');
expect(await isCached('1.1.0', 'a.tar.gz')).toBe(false);
});
});
describe('pruneCache', () => {
async function seed(versions: string[]) {
for (const v of versions) {
await fs.mkdir(path.join(tmpRoot, v), { recursive: true });
await fs.writeFile(path.join(tmpRoot, v, 'a.tar.gz'), 'x'.repeat(10));
}
}
it('conserva las N más recientes por orden numérico de versión', async () => {
env.CRAS_CACHE_KEEP_VERSIONS = '2';
// 1.10.0 es más nueva que 1.9.0: un orden lexicográfico borraría la equivocada.
await seed(['1.8.0', '1.9.0', '1.10.0']);
const removed = await pruneCache([]);
expect(removed).toEqual(['1.8.0']);
expect((await cacheUsage()).versions.map((v) => v.version).sort()).toEqual([
'1.10.0',
'1.9.0'
]);
});
it('nunca borra una versión fijada, aunque sea vieja', async () => {
// Dejar sin artefacto local a la versión activa obligaría a re-descargar 270 MB en
// plena instalación.
env.CRAS_CACHE_KEEP_VERSIONS = '1';
await seed(['1.8.0', '1.9.0', '1.10.0']);
const removed = await pruneCache(['1.8.0']);
expect(removed).not.toContain('1.8.0');
expect(await isCached('1.8.0', 'a.tar.gz')).toBe(true);
});
it('no borra nada si cabe dentro del límite', async () => {
env.CRAS_CACHE_KEEP_VERSIONS = '5';
await seed(['1.1.0', '1.2.0']);
expect(await pruneCache([])).toEqual([]);
});
});
describe('selectPrunableVersions', () => {
// Es la previsualización que ve el operador antes de confirmar la poda: tiene que decidir con
// el mismo criterio que pruneCache, o el modal prometería borrar algo distinto de lo que borra.
const usage = [
{ version: '1.8.0', bytes: 100 },
{ version: '1.9.0', bytes: 200 },
{ version: '1.10.0', bytes: 400 }
];
it('conserva las N más nuevas en orden numérico, no lexicográfico', () => {
// Con orden de texto "1.10.0" < "1.9.0" y se borraría la más nueva.
expect(selectPrunableVersions(usage, [], 2)).toEqual({ versions: ['1.8.0'], bytes: 100 });
expect(selectPrunableVersions(usage, [], 1)).toEqual({
versions: ['1.9.0', '1.8.0'],
bytes: 300
});
});
it('nunca propone una versión fijada y no la cuenta contra el límite', () => {
expect(selectPrunableVersions(usage, ['1.8.0'], 1)).toEqual({
versions: ['1.9.0'],
bytes: 200
});
});
it('suma exactamente los bytes de las versiones elegidas', () => {
const plan = selectPrunableVersions(usage, [], 0);
expect(plan.versions).toEqual(['1.10.0', '1.9.0', '1.8.0']);
expect(plan.bytes).toBe(700);
});
it('con menos versiones que el límite el plan es vacío: el clic no haría nada', () => {
// El control debe decir "Nada que liberar" en vez de ofrecer un borrado que es un no-op.
expect(selectPrunableVersions(usage, [], 5)).toEqual({ versions: [], bytes: 0 });
expect(selectPrunableVersions([], [], 3)).toEqual({ versions: [], bytes: 0 });
});
it('ignora carpetas que no son versiones válidas', () => {
const dirty = [...usage, { version: '../etc', bytes: 999 }, { version: 'tmp', bytes: 5 }];
expect(selectPrunableVersions(dirty, [], 3).versions).toEqual([]);
});
});

View File

@@ -0,0 +1,347 @@
/**
* Caché local de los artefactos de CloudRestoreAS.
*
* Los bytes viven en Gitea, pero el panel necesita una copia local para poder empujarlos
* por SFTP al servidor destino. La caché es perezosa: se baja la primera vez que se
* necesita una versión y se conserva para las siguientes instalaciones.
*
* Cada descarga se verifica contra el sha256 que reporta Gitea. Un artefacto truncado o
* corrupto NO debe llegar a un servidor de producción, así que se escribe a un archivo
* `.part`, se compara el hash y solo entonces se hace el rename — nunca queda un archivo
* con nombre definitivo sin verificar.
*
* Cuidar el espacio importa: cada artefacto pesa ~270 MB y se publican dos por versión.
*/
import { createHash } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { env } from '$env/dynamic/private';
import { compareVersions } from '$lib/cras-version';
import { openPackageFile } from './gitea-packages';
import { logger } from './logger';
const DEFAULT_CACHE_DIR = '/data/cras-releases';
const DEFAULT_KEEP_VERSIONS = 3;
export class ArtifactError extends Error {
constructor(
public status: number,
message: string
) {
super(message);
this.name = 'ArtifactError';
}
}
export function cacheDir(): string {
return env.CRAS_RELEASES_DIR || DEFAULT_CACHE_DIR;
}
function keepVersions(): number {
const raw = Number(env.CRAS_CACHE_KEEP_VERSIONS);
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : DEFAULT_KEEP_VERSIONS;
}
/**
* La versión y el nombre de archivo se usan para construir rutas, así que se validan de
* forma estricta en lugar de solo rechazar "..": ambos vienen de la API de Gitea, que es
* confiable pero no es parte de este código, y basta un nombre con separadores para
* escribir fuera de la caché.
*/
const SAFE_VERSION_RE = /^\d+(\.\d+){0,3}$/;
const SAFE_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
export function isSafeVersion(version: string): boolean {
return SAFE_VERSION_RE.test(String(version ?? '').trim());
}
export function isSafeFileName(fileName: string): boolean {
const name = String(fileName ?? '').trim();
return SAFE_FILE_RE.test(name) && !name.includes('..');
}
/** Ruta en caché de un artefacto. Lanza si version/fileName no son seguros. */
export function artifactPath(version: string, fileName: string): string {
if (!isSafeVersion(version)) {
throw new ArtifactError(400, `Versión inválida: ${version}`);
}
if (!isSafeFileName(fileName)) {
throw new ArtifactError(400, `Nombre de archivo inválido: ${fileName}`);
}
const resolved = path.resolve(cacheDir(), version, fileName);
// Cinturón y tirantes: aunque las regex ya lo impiden, se confirma que la ruta final
// quede dentro de la caché antes de escribir o leer.
const root = path.resolve(cacheDir()) + path.sep;
if (!resolved.startsWith(root)) {
throw new ArtifactError(400, 'Ruta de artefacto fuera de la caché');
}
return resolved;
}
export async function isCached(version: string, fileName: string): Promise<boolean> {
try {
const stat = await fs.stat(artifactPath(version, fileName));
return stat.isFile() && stat.size > 0;
} catch {
return false;
}
}
export async function cachedSize(version: string, fileName: string): Promise<number | null> {
try {
const stat = await fs.stat(artifactPath(version, fileName));
return stat.isFile() ? stat.size : null;
} catch {
return null;
}
}
/** Transform que va alimentando el hash mientras los bytes pasan hacia el disco. */
function hashingPassThrough(hash: ReturnType<typeof createHash>): Transform {
return new Transform({
transform(chunk, _encoding, callback) {
hash.update(chunk);
callback(null, chunk);
}
});
}
export interface EnsureCachedResult {
path: string;
downloaded: boolean;
size: number;
}
/**
* Garantiza que el artefacto esté en caché y verificado. Devuelve su ruta local.
*
* Si ya está en caché no se re-descarga ni se re-verifica: el rename solo ocurre tras una
* verificación exitosa, así que la presencia del archivo definitivo ya implica que su hash
* fue correcto en su momento. `expectedSha256` se exige para poder verificar; sin él se
* rechaza en lugar de bajar 270 MB a ciegas.
*/
export async function ensureCached(
version: string,
fileName: string,
expectedSha256: string | null,
expectedSize?: number | null
): Promise<EnsureCachedResult> {
const target = artifactPath(version, fileName);
const existing = await cachedSize(version, fileName);
if (existing !== null && existing > 0) {
return { path: target, downloaded: false, size: existing };
}
const expected = (expectedSha256 ?? '').trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(expected)) {
throw new ArtifactError(
409,
`La versión ${version} no tiene sha256 registrado; sincroniza con Gitea antes de instalar`
);
}
await fs.mkdir(path.dirname(target), { recursive: true });
// El .part lleva el pid para que dos descargas concurrentes de la misma versión no se
// pisen el archivo temporal entre ellas.
const partial = `${target}.${process.pid}.part`;
logger.info({
message: 'Descargando artefacto de CRAS desde Gitea',
context: { version, file_name: fileName, expected_size: expectedSize ?? null }
});
const hash = createHash('sha256');
let written = 0;
try {
const download = await openPackageFile(version, fileName);
const counter = new Transform({
transform(chunk, _encoding, callback) {
written += chunk.length;
callback(null, chunk);
}
});
await pipeline(
Readable.fromWeb(download.body as never),
hashingPassThrough(hash),
counter,
createWriteStream(partial, { highWaterMark: 1024 * 1024 })
);
} catch (err) {
await fs.rm(partial, { force: true });
if (err instanceof ArtifactError) throw err;
const message = err instanceof Error ? err.message : String(err);
throw new ArtifactError(502, `Falló la descarga de ${fileName}: ${message}`);
}
const actual = hash.digest('hex');
if (actual !== expected) {
await fs.rm(partial, { force: true });
logger.error({
message: 'sha256 del artefacto no coincide; descarga descartada',
context: { version, file_name: fileName, expected, actual }
});
throw new ArtifactError(
502,
`El sha256 de ${fileName} no coincide con el publicado en Gitea (descarga corrupta o incompleta)`
);
}
if (typeof expectedSize === 'number' && expectedSize > 0 && written !== expectedSize) {
await fs.rm(partial, { force: true });
throw new ArtifactError(
502,
`El tamaño de ${fileName} no coincide (esperado ${expectedSize}, recibido ${written})`
);
}
// Rename atómico: el nombre definitivo solo existe si el hash cuadró.
await fs.rename(partial, target);
logger.info({
message: 'Artefacto de CRAS cacheado y verificado',
context: { version, file_name: fileName, size: written, path: target }
});
return { path: target, downloaded: true, size: written };
}
export async function removeCached(version: string, fileName: string): Promise<void> {
await fs.rm(artifactPath(version, fileName), { force: true });
// Si la carpeta de la versión quedó vacía se retira para no acumular directorios.
try {
await fs.rmdir(path.dirname(artifactPath(version, fileName)));
} catch {
// Queda contenido de otra plataforma: se conserva.
}
}
export interface CacheUsage {
total_bytes: number;
versions: { version: string; bytes: number; files: number }[];
}
/** Uso de disco de la caché, por versión. Alimenta el aviso de espacio en la UI. */
export async function cacheUsage(): Promise<CacheUsage> {
const root = cacheDir();
const usage: CacheUsage = { total_bytes: 0, versions: [] };
let entries: string[];
try {
entries = await fs.readdir(root);
} catch {
return usage; // la caché aún no existe
}
for (const entry of entries) {
if (!isSafeVersion(entry)) continue;
let files: string[];
try {
files = await fs.readdir(path.join(root, entry));
} catch {
continue;
}
let bytes = 0;
let count = 0;
for (const file of files) {
if (file.endsWith('.part')) continue;
try {
const stat = await fs.stat(path.join(root, entry, file));
if (stat.isFile()) {
bytes += stat.size;
count += 1;
}
} catch {
continue;
}
}
usage.total_bytes += bytes;
usage.versions.push({ version: entry, bytes, files: count });
}
usage.versions.sort((a, b) => b.bytes - a.bytes);
return usage;
}
export interface PrunePlan {
versions: string[];
bytes: number;
}
/**
* Qué versiones de la caché se podarían: las no fijadas que quedan fuera de las `keepLimit` más
* nuevas, en orden numérico de versión (1.10.0 es más nueva que 1.9.0).
*
* Pura y exportada a propósito: la UI necesita previsualizar el borrado con EXACTAMENTE el mismo
* criterio con el que se ejecuta. Antes el botón "Liberar versiones viejas" no decía cuánto
* liberaba, y con ≤3 versiones en caché no borraba nada — un control destructivo que a veces era
* un no-op silencioso.
*/
export function selectPrunableVersions(
versions: { version: string; bytes: number }[],
pinnedVersions: string[],
keepLimit: number
): PrunePlan {
const pinned = new Set(pinnedVersions.filter(isSafeVersion));
// Orden por versión descendente: las más nuevas se conservan.
const candidates = versions
.filter((entry) => isSafeVersion(entry.version))
.sort((a, b) => (compareVersions(b.version, a.version) ?? 0));
const plan: PrunePlan = { versions: [], bytes: 0 };
let kept = 0;
for (const candidate of candidates) {
if (pinned.has(candidate.version)) continue;
kept += 1;
if (kept <= keepLimit) continue;
plan.versions.push(candidate.version);
plan.bytes += candidate.bytes;
}
return plan;
}
/** Plan de poda con el límite configurado. Lo consume la UI para confirmar antes de borrar. */
export async function prunePlan(pinnedVersions: string[] = []): Promise<PrunePlan> {
const usage = await cacheUsage();
return selectPrunableVersions(usage.versions, pinnedVersions, keepVersions());
}
/**
* Poda la caché conservando las versiones indicadas y las más recientes hasta el límite.
*
* `pinnedVersions` son las versiones que NO se pueden borrar (típicamente las activas): dejar sin
* artefacto local a una versión activa obligaría a re-descargar 270 MB en plena instalación.
* Devuelve las versiones eliminadas para poder reportarlas — una poda silenciosa se lee como "no
* pasó nada" cuando en realidad se liberó disco.
*/
export async function pruneCache(pinnedVersions: string[] = []): Promise<string[]> {
const limit = keepVersions();
const usage = await cacheUsage();
const plan = selectPrunableVersions(usage.versions, pinnedVersions, limit);
const removed: string[] = [];
for (const version of plan.versions) {
try {
await fs.rm(path.join(cacheDir(), version), { recursive: true, force: true });
removed.push(version);
} catch (err) {
logger.warn({
message: 'No se pudo podar una versión de la caché',
context: { version, error: err instanceof Error ? err.message : String(err) }
});
}
}
if (removed.length) {
logger.info({
message: 'Caché de artefactos podada',
context: {
removed,
keep_limit: limit,
pinned: pinnedVersions.filter(isSafeVersion)
}
});
}
return removed;
}

View File

@@ -0,0 +1,202 @@
/**
* Instalador remoto de CloudRestoreAS: utilidades de ejecución y quoting.
*
* La prueba más importante de este archivo es la de fuga de secretos: el token del panel
* NUNCA debe aparecer en un comando remoto, porque `ps` y el historial del servidor destino
* son legibles por otros usuarios. Es exactamente el defecto del instalador de AServers, que
* hace `echo '{password}' | sudo -S ...`.
*/
import { describe, expect, it, vi } from 'vitest';
import { execRemote, psEncoded, shQuote, InstallError } from './cras-install';
/** Cliente SFTP falso que expone un `client.exec` controlable, como el real. */
function fakeSftp(handler: (command: string) => { code?: number; stdout?: string; stderr?: string }) {
const commands: string[] = [];
const sftp = {
client: {
exec(command: string, callback: (err: Error | null, stream?: unknown) => void) {
commands.push(command);
const result = handler(command);
const listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
const stderrListeners: ((...args: unknown[]) => void)[] = [];
const stream = {
on(event: string, fn: (...args: unknown[]) => void) {
(listeners[event] ??= []).push(fn);
return stream;
},
stderr: {
on(_event: string, fn: (...args: unknown[]) => void) {
stderrListeners.push(fn);
return stream.stderr;
}
}
};
callback(null, stream);
// Se emite en el mismo orden que ssh2: data → exit → close.
setImmediate(() => {
if (result.stdout) {
for (const fn of listeners.data ?? []) fn(Buffer.from(result.stdout));
}
if (result.stderr) {
for (const fn of stderrListeners) fn(Buffer.from(result.stderr));
}
for (const fn of listeners.exit ?? []) fn(result.code ?? 0);
for (const fn of listeners.close ?? []) fn();
});
}
}
};
return { sftp, commands };
}
describe('shQuote', () => {
it('envuelve en comillas simples', () => {
expect(shQuote('/tmp/cras-1.1.0')).toBe("'/tmp/cras-1.1.0'");
});
it('escapa comillas simples internas', () => {
// Sin esto, un nombre con apóstrofo cerraría la comilla y el resto se ejecutaría.
expect(shQuote("a'b")).toBe("'a'\\''b'");
});
it('neutraliza metacaracteres de shell', () => {
const quoted = shQuote('x; rm -rf /');
expect(quoted).toBe("'x; rm -rf /'");
// El ; queda dentro de las comillas, así que no separa comandos.
expect(quoted.startsWith("'")).toBe(true);
expect(quoted.endsWith("'")).toBe(true);
});
});
describe('psEncoded', () => {
it('codifica en base64 UTF-16LE, como espera -EncodedCommand', () => {
const command = psEncoded('Write-Output "hola"');
expect(command).toContain('-EncodedCommand');
const base64 = command.split('-EncodedCommand ')[1];
expect(Buffer.from(base64, 'base64').toString('utf16le')).toBe('Write-Output "hola"');
});
it('usa -NoProfile y -NonInteractive para que no cuelgue esperando entrada', () => {
const command = psEncoded('$x = 1');
expect(command).toContain('-NoProfile');
expect(command).toContain('-NonInteractive');
expect(command).toContain('-ExecutionPolicy Bypass');
});
it('sobrevive a comillas y rutas de Windows sin escapes manuales', () => {
// Es la razón de usar -EncodedCommand: el script viaja intacto por cmd.exe.
const script = `& 'C:\\Aduanasoft\\CloudRestoreAS\\install.ps1' -Service`;
const base64 = psEncoded(script).split('-EncodedCommand ')[1];
expect(Buffer.from(base64, 'base64').toString('utf16le')).toBe(script);
});
});
describe('execRemote', () => {
it('devuelve código, stdout y stderr recortados', async () => {
const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'Linux\n', stderr: ' aviso ' }));
const result = await execRemote(sftp as never, 'uname -s');
expect(result).toEqual({ code: 0, stdout: 'Linux', stderr: 'aviso' });
});
it('propaga un código de salida distinto de cero sin lanzar', async () => {
// El llamador decide qué hacer con cada código; lanzar aquí perdería el stderr.
const { sftp } = fakeSftp(() => ({ code: 2, stderr: 'no existe' }));
const result = await execRemote(sftp as never, 'command -v tar');
expect(result.code).toBe(2);
expect(result.stderr).toBe('no existe');
});
it('rechaza si la conexión no expone exec()', async () => {
await expect(execRemote({} as never, 'ls')).rejects.toThrow(InstallError);
});
it('rechaza con 504 al vencer el timeout', async () => {
const sftp = {
client: {
exec(_command: string, callback: (err: Error | null, stream?: unknown) => void) {
// Nunca emite close: simula un comando colgado en el destino.
callback(null, {
on() {
return this;
},
stderr: {
on() {
return this;
}
}
});
}
}
};
await expect(execRemote(sftp as never, 'sleep 999', 30)).rejects.toMatchObject({
status: 504
});
});
it('propaga el error de exec como InstallError', async () => {
const sftp = {
client: {
exec(_command: string, callback: (err: Error | null) => void) {
callback(new Error('canal rechazado'));
}
}
};
await expect(execRemote(sftp as never, 'ls')).rejects.toThrow(/canal rechazado/);
});
});
describe('no fuga de secretos en los comandos remotos', () => {
const TOKEN = 'tok-super-secreto-abc123';
it('el flujo de comandos nunca contiene el token del panel', async () => {
// Se simula la secuencia de comandos que arma installLinux y se verifica que ninguno
// incluya el token: el token debe viajar SOLO en el archivo subido por SFTP.
const remoteDir = '/tmp/cras-1.1.0-7';
const remoteEnv = `${remoteDir}/panel.env`;
const comandos = [
'uname -s',
'id -u',
'sudo -n true',
'command -v tar',
`mkdir -p ${shQuote(remoteDir)} && chmod 700 ${shQuote(remoteDir)}`,
`sha256sum ${shQuote(`${remoteDir}/CloudRestoreAS-1.1.0-linux-x86_64.tar.gz`)}`,
`tar xzf ${shQuote(`${remoteDir}/pkg.tar.gz`)} -C ${shQuote(remoteDir)}`,
`cd ${shQuote(`${remoteDir}/CloudRestoreAS`)} && sudo -n ./install.sh --service --panel-env-file ${shQuote(remoteEnv)}`,
`cat ${shQuote(`/opt/cloudrestoreas/config/.version`)} 2>/dev/null`,
`rm -rf ${shQuote(remoteDir)}`
];
for (const comando of comandos) {
expect(comando, comando).not.toContain(TOKEN);
}
// El instalador solo recibe la RUTA del archivo con el token, no su contenido.
const installCmd = comandos.find((c) => c.includes('install.sh'));
expect(installCmd).toContain('--panel-env-file');
expect(installCmd).toContain(remoteEnv);
});
it('sudo se invoca con -n y jamás recibe una contraseña por stdin', async () => {
const { sftp, commands } = fakeSftp(() => ({ code: 0, stdout: '' }));
await execRemote(sftp as never, 'sudo -n true');
await execRemote(sftp as never, 'sudo -n systemctl is-active cloudrestoreas');
for (const command of commands) {
expect(command).toContain('-n');
// El antipatrón de AServers: echo '<password>' | sudo -S ...
expect(command).not.toMatch(/\|\s*sudo\s+-S/);
expect(command).not.toMatch(/echo\s+['"].*['"]\s*\|/);
}
});
it('el script de PowerShell del instalador tampoco lleva el token', () => {
const remoteEnv = 'C:\\Users\\svc\\AppData\\Local\\Temp\\cras\\panel.env';
const command = psEncoded(
`& 'C:\\x\\install.ps1' -Service -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE`
);
const decoded = Buffer.from(command.split('-EncodedCommand ')[1], 'base64').toString(
'utf16le'
);
expect(decoded).not.toContain(TOKEN);
expect(decoded).toContain('-PanelEnvFile');
});
});

View 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;
}

View File

@@ -0,0 +1,620 @@
/**
* Catálogo de versiones de CloudRestoreAS y bitácora de instalaciones.
*
* `cras_releases` guarda solo METADATOS: los bytes viven en Gitea y se cachean en disco
* (ver cras-artifacts.ts). Un artefacto de CRAS pesa ~270 MB, así que guardarlo como BLOB
* en Postgres —como hace AServers con el agente Jhona, que pesa 15 MB— no es viable.
*
* El DDL autoritativo está en la migración e2f3a4b5c6d7 de a24c. Aquí se replica como
* fallback idempotente, igual que ensureRestoreTargetsSchema en controldesk-pg.ts: el panel
* y a24c comparten la base y el orden de arranque no está garantizado.
*/
import { pgPool } from './db';
import { logger } from './logger';
import {
compareVersions,
effectiveArch,
effectivePlatform,
isNewer,
type CrasPlatform
} from '$lib/cras-version';
const SCHEMA = 'a24c';
function qReleases(): string {
return `"${SCHEMA}"."cras_releases"`;
}
function qInstallRuns(): string {
return `"${SCHEMA}"."cras_install_runs"`;
}
function qRestoreTargets(): string {
return `"${SCHEMA}"."restore_targets"`;
}
function qCloudRestoreStatus(): string {
return `"${SCHEMA}"."cloudrestore_status"`;
}
function isPgUndefinedTable(err: unknown): boolean {
return typeof err === 'object' && err !== null && (err as { code?: string }).code === '42P01';
}
function isPgUndefinedColumn(err: unknown): boolean {
return typeof err === 'object' && err !== null && (err as { code?: string }).code === '42703';
}
/** True si el error es una violación de unicidad (23505). */
export function isPgUniqueViolation(err: unknown): boolean {
return typeof err === 'object' && err !== null && (err as { code?: string }).code === '23505';
}
/**
* DDL de respaldo, en paridad con la migración e2f3a4b5c6d7. Idempotente.
* Si se agrega una columna hay que hacerlo en AMBOS lados o divergen según quién arranque
* primero, el panel o a24c.
*/
export async function ensureCrasSchema(): Promise<void> {
await pgPool.query('CREATE SCHEMA IF NOT EXISTS a24c');
await pgPool.query(`
CREATE TABLE IF NOT EXISTS ${qReleases()} (
id SERIAL PRIMARY KEY,
version VARCHAR(50) NOT NULL,
platform VARCHAR(20) NOT NULL,
arch VARCHAR(20) NOT NULL DEFAULT 'x86_64',
file_name VARCHAR(255) NOT NULL,
file_size BIGINT,
sha256 VARCHAR(64),
gitea_package VARCHAR(120) NOT NULL DEFAULT 'cloudrestoreas',
changelog TEXT,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
published_at TIMESTAMPTZ,
discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT cras_releases_platform_check CHECK (platform IN ('windows', 'linux')),
CONSTRAINT cras_releases_version_platform_arch_key UNIQUE (version, platform, arch)
)
`);
await pgPool.query(
`CREATE UNIQUE INDEX IF NOT EXISTS idx_a24c_cras_releases_active
ON ${qReleases()} (platform, arch) WHERE is_active`
);
await pgPool.query(
`CREATE INDEX IF NOT EXISTS idx_a24c_cras_releases_version ON ${qReleases()} (version)`
);
// Las FK se declaran igual que en la migración e2f3a4b5c6d7 y en database/schema.sql. Sin
// esto, si el panel arranca antes que a24c la tabla quedaría SIN FK para siempre (la
// migración la ve existente y no la toca), dejando bitácora con restore_target_id colgante
// al borrar un servidor. Si restore_targets aún no existe se cae al DDL sin FK, porque es
// preferible una bitácora sin integridad referencial a que no arranque el panel.
const installRunsDdl = (withFk: boolean) => `
CREATE TABLE IF NOT EXISTS ${qInstallRuns()} (
id SERIAL PRIMARY KEY,
restore_target_id INTEGER${withFk ? ` REFERENCES ${qRestoreTargets()} (id) ON DELETE SET NULL` : ''},
release_id INTEGER${withFk ? ` REFERENCES ${qReleases()} (id) ON DELETE SET NULL` : ''},
version VARCHAR(50),
platform VARCHAR(20),
mode VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
install_path VARCHAR(500),
steps JSONB NOT NULL DEFAULT '[]'::jsonb,
error_message TEXT,
started_by VARCHAR(128),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
CONSTRAINT cras_install_runs_mode_check CHECK (mode IN ('install', 'update')),
CONSTRAINT cras_install_runs_status_check
CHECK (status IN ('running', 'completed', 'failed'))
)
`;
try {
await pgPool.query(installRunsDdl(true));
} catch (e) {
if (!isPgUndefinedTable(e)) throw e;
logger.warn({
message: 'cras_install_runs creada sin claves foráneas: falta a24c.restore_targets',
context: { hint: 'aplica la migración de a24c para obtener la integridad referencial' }
});
await pgPool.query(installRunsDdl(false));
}
await pgPool.query(
`CREATE INDEX IF NOT EXISTS idx_a24c_cras_install_runs_target
ON ${qInstallRuns()} (restore_target_id, started_at DESC)`
);
await pgPool.query(
`CREATE INDEX IF NOT EXISTS idx_a24c_cras_install_runs_running
ON ${qInstallRuns()} (restore_target_id) WHERE status = 'running'`
);
// Columnas que reporta el agente; la migración de a24c también las agrega.
for (const column of [
'platform VARCHAR(20)',
'arch VARCHAR(20)',
'install_path VARCHAR(500)'
]) {
try {
await pgPool.query(
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS ${column}`
);
} catch (e) {
if (!isPgUndefinedTable(e)) throw e; // la tabla la crea controldesk-pg/a24c
}
}
}
// ============================================================================
// Catálogo de versiones
// ============================================================================
export interface CrasRelease {
id: number;
version: string;
platform: CrasPlatform;
arch: string;
file_name: string;
file_size: number | null;
sha256: string | null;
gitea_package: string;
changelog: string | null;
is_active: boolean;
published_at: Date | null;
discovered_at: Date;
}
const ROW_RELEASE = `
id, version, platform, arch, file_name, file_size, sha256,
gitea_package, changelog, is_active, published_at, discovered_at
`;
async function queryReleases(): Promise<CrasRelease[]> {
const r = await pgPool.query(
`SELECT ${ROW_RELEASE} FROM ${qReleases()} ORDER BY discovered_at DESC, version DESC`
);
return r.rows as CrasRelease[];
}
/**
* Todas las versiones registradas, más nuevas primero. El orden final lo decide
* compareVersions y no el ORDER BY: en SQL "1.10.0" < "1.9.0" como texto.
*/
export async function listCrasReleases(): Promise<CrasRelease[]> {
let rows: CrasRelease[];
try {
rows = await queryReleases();
} catch (e) {
if (!isPgUndefinedTable(e) && !isPgUndefinedColumn(e)) throw e;
await ensureCrasSchema();
rows = await queryReleases();
}
return rows.sort(
(a, b) =>
(compareVersions(b.version, a.version) ?? 0) ||
a.platform.localeCompare(b.platform) ||
a.arch.localeCompare(b.arch)
);
}
export async function getCrasReleaseById(id: number): Promise<CrasRelease | null> {
const r = await pgPool.query(`SELECT ${ROW_RELEASE} FROM ${qReleases()} WHERE id = $1`, [id]);
return (r.rows[0] as CrasRelease) ?? null;
}
/** Versión activa para una plataforma+arquitectura, si hay. */
export async function getActiveCrasRelease(
platform: string,
arch: string
): Promise<CrasRelease | null> {
try {
const r = await pgPool.query(
`SELECT ${ROW_RELEASE} FROM ${qReleases()}
WHERE is_active AND platform = $1 AND arch = $2 LIMIT 1`,
[platform, arch]
);
return (r.rows[0] as CrasRelease) ?? null;
} catch (e) {
if (isPgUndefinedTable(e)) return null;
throw e;
}
}
export interface CrasReleaseInput {
version: string;
platform: CrasPlatform;
arch: string;
file_name: string;
file_size: number | null;
sha256: string | null;
gitea_package: string;
published_at: Date | null;
}
/**
* Registra o actualiza un artefacto descubierto en Gitea. No toca `is_active`: activar es
* una decisión explícita del operador, no algo que un sync deba hacer por su cuenta —
* sincronizar no debe cambiar qué versión se está desplegando.
*
* Devuelve true si la fila es nueva.
*/
export async function upsertCrasRelease(input: CrasReleaseInput): Promise<boolean> {
const r = await pgPool.query(
`
INSERT INTO ${qReleases()} (
version, platform, arch, file_name, file_size, sha256, gitea_package, published_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (version, platform, arch) DO UPDATE SET
file_name = EXCLUDED.file_name,
file_size = EXCLUDED.file_size,
sha256 = EXCLUDED.sha256,
gitea_package = EXCLUDED.gitea_package,
published_at = COALESCE(EXCLUDED.published_at, ${qReleases()}.published_at)
RETURNING (xmax = 0) AS inserted
`,
[
input.version,
input.platform,
input.arch,
input.file_name,
input.file_size,
input.sha256,
input.gitea_package,
input.published_at
]
);
return Boolean(r.rows[0]?.inserted);
}
/**
* Marca una versión como activa para su plataforma+arquitectura.
*
* Va en transacción porque el índice único parcial solo admite una activa por tupla:
* desactivar y activar tienen que ocurrir juntos o el UPDATE choca consigo mismo.
*/
export async function activateCrasRelease(id: number): Promise<CrasRelease | null> {
const client = await pgPool.connect();
try {
await client.query('BEGIN');
const current = await client.query(
`SELECT ${ROW_RELEASE} FROM ${qReleases()} WHERE id = $1 FOR UPDATE`,
[id]
);
const release = current.rows[0] as CrasRelease | undefined;
if (!release) {
await client.query('ROLLBACK');
return null;
}
await client.query(
`UPDATE ${qReleases()} SET is_active = FALSE
WHERE platform = $1 AND arch = $2 AND is_active AND id <> $3`,
[release.platform, release.arch, id]
);
await client.query(`UPDATE ${qReleases()} SET is_active = TRUE WHERE id = $1`, [id]);
await client.query('COMMIT');
logger.info({
message: 'Versión de CRAS activada',
context: { id, version: release.version, platform: release.platform, arch: release.arch }
});
return { ...release, is_active: true };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
export async function deactivateCrasRelease(id: number): Promise<void> {
await pgPool.query(`UPDATE ${qReleases()} SET is_active = FALSE WHERE id = $1`, [id]);
}
/**
* Borra una versión del catálogo. No borra nada en Gitea (los paquetes genéricos son
* inmutables y son el respaldo) ni la caché local, que se poda aparte.
*/
export async function deleteCrasRelease(id: number): Promise<void> {
await pgPool.query(`DELETE FROM ${qReleases()} WHERE id = $1`, [id]);
}
// ============================================================================
// Inventario: qué versión tiene cada servidor vs. la activa que le toca
// ============================================================================
export interface CrasTargetInventory {
restore_target_id: number;
name: string;
server_ip: string | null;
os: string | null;
ssh_host: string | null;
ssh_username: string | null;
has_ssh_credentials: boolean;
/** Lo que reportó el agente (autoritativo). */
reported_platform: string | null;
reported_arch: string | null;
/** Dónde vive el ejecutable según el agente. Null si aún no ha reportado. */
reported_install_path: string | null;
/** Carpetas de trabajo reportadas. Sirven para no instalar el binario dentro de ellas. */
input_folder: string | null;
processed_folder: string | null;
installed_version: string | null;
reported_at: Date | null;
/** Resuelto: reportado, o derivado de `os` para la primera instalación. */
platform: CrasPlatform | null;
arch: string;
active_version: string | null;
active_release_id: number | null;
update_available: boolean;
/** Instalación en curso, si hay. */
running_install_id: number | null;
}
/**
* Cruza servidores de restauración con lo que reportó su agente y con la versión activa.
*
* El join instancia↔servidor es por nombre (LOWER(TRIM(instance_key)) = LOWER(TRIM(name))),
* la misma convención que ya usa resolve-route: no hay FK entre esas tablas.
*/
export async function listCrasTargetInventory(): Promise<CrasTargetInventory[]> {
await ensureCrasSchema();
const r = await pgPool.query(`
SELECT rt.id AS restore_target_id,
rt.name,
rt.server_ip,
rt.os,
rt.ssh_host,
rt.ssh_username,
(rt.ssh_password_encrypted IS NOT NULL
AND rt.ssh_password_encrypted <> '') AS has_ssh_credentials,
cs.platform AS reported_platform,
cs.arch AS reported_arch,
cs.install_path AS reported_install_path,
cs.input_folder,
cs.processed_folder,
cs.app_version AS installed_version,
cs.reported_at,
run.id AS running_install_id
FROM ${qRestoreTargets()} rt
LEFT JOIN ${qCloudRestoreStatus()} cs
ON LOWER(TRIM(cs.instance_key)) = LOWER(TRIM(rt.name))
LEFT JOIN LATERAL (
SELECT id FROM ${qInstallRuns()}
WHERE restore_target_id = rt.id AND status = 'running'
ORDER BY started_at DESC LIMIT 1
) run ON TRUE
ORDER BY rt.name
`);
// Las activas se leen una vez y se resuelven en memoria: son a lo más una por
// plataforma+arch, así que no vale la pena un join por fila.
const releases = await listCrasReleases();
const activeByKey = new Map<string, CrasRelease>();
for (const release of releases) {
if (release.is_active) activeByKey.set(`${release.platform}/${release.arch}`, release);
}
return r.rows.map((row) => {
const platform = effectivePlatform(row.reported_platform, row.os);
const arch = effectiveArch(row.reported_arch);
const active = platform ? (activeByKey.get(`${platform}/${arch}`) ?? null) : null;
return {
...row,
has_ssh_credentials: Boolean(row.has_ssh_credentials),
platform,
arch,
active_version: active?.version ?? null,
active_release_id: active?.id ?? null,
update_available: active ? isNewer(active.version, row.installed_version) : false,
running_install_id: row.running_install_id ?? null
} as CrasTargetInventory;
});
}
// ============================================================================
// Bitácora de instalaciones
// ============================================================================
export type InstallMode = 'install' | 'update';
export type InstallStatus = 'running' | 'completed' | 'failed';
export interface InstallStep {
step: string;
detail?: string;
at: string;
ok: boolean;
}
export interface CrasInstallRun {
id: number;
restore_target_id: number | null;
release_id: number | null;
version: string | null;
platform: string | null;
mode: InstallMode;
status: InstallStatus;
steps: InstallStep[];
error_message: string | null;
started_by: string | null;
started_at: Date;
finished_at: Date | null;
target_name?: string | null;
}
/**
* Ya hay una instalación abierta para ese destino: es un conflicto de estado, no un fallo
* interno. Se distingue con una clase propia porque un `Error` pelón terminaba mapeado a
* 500 "Error interno" en la action, y el operador no podía saber que solo tenía que esperar.
*/
export class InstallRunConflictError extends Error {
constructor(public readonly runId: number) {
super(`Ya hay una instalación en curso para este servidor (run #${runId})`);
this.name = 'InstallRunConflictError';
}
}
/**
* Abre un run. Falla si ya hay uno corriendo para ese destino: dos instalaciones
* simultáneas se pelearían el mismo binario y el mismo servicio en el servidor.
*/
export async function startInstallRun(input: {
restoreTargetId: number;
releaseId: number;
version: string;
platform: string;
mode: InstallMode;
startedBy: string | null;
}): Promise<number> {
await ensureCrasSchema();
const running = await pgPool.query(
`SELECT id FROM ${qInstallRuns()} WHERE restore_target_id = $1 AND status = 'running' LIMIT 1`,
[input.restoreTargetId]
);
if (running.rows[0]) {
throw new InstallRunConflictError(running.rows[0].id as number);
}
const r = await pgPool.query(
`INSERT INTO ${qInstallRuns()}
(restore_target_id, release_id, version, platform, mode, status, started_by)
VALUES ($1, $2, $3, $4, $5, 'running', $6)
RETURNING id`,
[
input.restoreTargetId,
input.releaseId,
input.version,
input.platform,
input.mode,
input.startedBy
]
);
return r.rows[0].id as number;
}
/**
* Agrega un paso al progreso. Se persiste de inmediato para que la UI lo pueda leer por
* polling mientras la instalación sigue corriendo: en AServers el progreso solo quedaba en
* el log del backend porque el schema de respuesta lo descartaba en silencio.
*/
export async function appendInstallStep(
runId: number,
step: string,
ok: boolean,
detail?: string
): Promise<void> {
const entry: InstallStep = { step, ok, at: new Date().toISOString() };
if (detail) entry.detail = detail;
try {
await pgPool.query(
`UPDATE ${qInstallRuns()} SET steps = steps || $2::jsonb WHERE id = $1`,
[runId, JSON.stringify([entry])]
);
} catch (err) {
// El progreso es informativo: si no se puede registrar, la instalación sigue.
logger.warn({
message: 'No se pudo registrar un paso de instalación',
context: { run_id: runId, step, error: err instanceof Error ? err.message : String(err) }
});
}
}
export async function finishInstallRun(
runId: number,
status: Exclude<InstallStatus, 'running'>,
errorMessage: string | null = null
): Promise<void> {
await pgPool.query(
`UPDATE ${qInstallRuns()}
SET status = $2, error_message = $3, finished_at = now()
WHERE id = $1`,
[runId, status, errorMessage]
);
}
export async function getInstallRun(runId: number): Promise<CrasInstallRun | null> {
try {
const r = await pgPool.query(
`SELECT run.*, rt.name AS target_name
FROM ${qInstallRuns()} run
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = run.restore_target_id
WHERE run.id = $1`,
[runId]
);
return (r.rows[0] as CrasInstallRun) ?? null;
} catch (e) {
if (isPgUndefinedTable(e)) return null;
throw e;
}
}
/**
* Último run de un destino. Lo usa la UI para seguir el progreso: la acción de instalar es
* bloqueante, así que el navegador no conoce el runId hasta que termina y necesita poder
* preguntar "¿qué está pasando en este servidor?" mientras espera.
*/
export async function getLatestInstallRunForTarget(
restoreTargetId: number
): Promise<CrasInstallRun | null> {
try {
const r = await pgPool.query(
`SELECT run.*, rt.name AS target_name
FROM ${qInstallRuns()} run
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = run.restore_target_id
WHERE run.restore_target_id = $1
ORDER BY run.started_at DESC
LIMIT 1`,
[restoreTargetId]
);
return (r.rows[0] as CrasInstallRun) ?? null;
} catch (e) {
if (isPgUndefinedTable(e)) return null;
throw e;
}
}
export async function listInstallRuns(limit = 30): Promise<CrasInstallRun[]> {
const capped = Number.isFinite(limit) && limit > 0 ? Math.min(Math.floor(limit), 200) : 30;
try {
await ensureCrasSchema();
const r = await pgPool.query(
`SELECT run.*, rt.name AS target_name
FROM ${qInstallRuns()} run
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = run.restore_target_id
ORDER BY run.started_at DESC
LIMIT $1`,
[capped]
);
return r.rows as CrasInstallRun[];
} catch (e) {
if (isPgUndefinedTable(e)) return [];
throw e;
}
}
/**
* Cierra como fallidos los runs que quedaron 'running' de un proceso anterior.
*
* Un reinicio del panel mata la instalación en curso pero deja la fila abierta, y esa fila
* bloquearía para siempre nuevas instalaciones en ese destino. Se llama al cargar la UI.
*/
export async function reapStaleInstallRuns(olderThanMinutes = 30): Promise<number> {
const minutes = Number.isFinite(olderThanMinutes) ? Math.max(1, olderThanMinutes) : 30;
try {
const r = await pgPool.query(
`UPDATE ${qInstallRuns()}
SET status = 'failed',
error_message = COALESCE(error_message,
'Interrumpida: el panel se reinició durante la instalación'),
finished_at = now()
WHERE status = 'running'
AND started_at < now() - ($1 || ' minutes')::interval`,
[String(minutes)]
);
return r.rowCount ?? 0;
} catch (e) {
if (isPgUndefinedTable(e)) return 0;
throw e;
}
}

View File

@@ -0,0 +1,68 @@
/**
* Parseo de nombres de artefacto de CloudRestoreAS.
*
* El nombre es lo que determina a qué plataforma y arquitectura corresponde cada binario. Si
* el parseo falla en silencio, el artefacto queda invisible en el catálogo y el servidor
* correspondiente nunca recibe la actualización — un síntoma muy difícil de diagnosticar.
*/
import { describe, expect, it } from 'vitest';
import { parseArtifactName } from './cras-sync';
describe('parseArtifactName', () => {
it('reconoce el artefacto de Linux', () => {
expect(parseArtifactName('CloudRestoreAS-1.1.0-linux-x86_64.tar.gz')).toEqual({
version: '1.1.0',
platform: 'linux',
arch: 'x86_64'
});
});
it('traduce el token "win" del nombre a la plataforma "windows"', () => {
// En el nombre se abrevia, pero el vocabulario del catálogo (y del CHECK de la tabla)
// es el que reporta el agente: "windows".
expect(parseArtifactName('CloudRestoreAS-1.1.0-win-x86_64.zip')).toEqual({
version: '1.1.0',
platform: 'windows',
arch: 'x86_64'
});
});
it('acepta versiones de 1 a 4 componentes', () => {
expect(parseArtifactName('CloudRestoreAS-2-linux-x86_64.tar.gz')?.version).toBe('2');
expect(parseArtifactName('CloudRestoreAS-26.7.1.4-linux-x86_64.tar.gz')?.version).toBe(
'26.7.1.4'
);
});
it('acepta otras arquitecturas y las normaliza a minúsculas', () => {
expect(parseArtifactName('CloudRestoreAS-1.1.0-linux-arm64.tar.gz')?.arch).toBe('arm64');
expect(parseArtifactName('CloudRestoreAS-1.1.0-linux-ARM64.tar.gz')?.arch).toBe('arm64');
});
it('rechaza los archivos de verificación que se publican junto a los paquetes', () => {
expect(parseArtifactName('SHA256SUMS')).toBeNull();
expect(parseArtifactName('release.json')).toBeNull();
});
it('rechaza nombres que no son artefactos de despliegue', () => {
for (const name of [
'CloudRestoreAS.exe', // binario crudo, no el paquete
'CloudRestoreAS-linux', // sin versión (esquema anterior)
'CloudRestoreAS-win.zip', // sin versión ni arch (esquema anterior)
'CloudRestoreAS-1.1.0-macos-arm64.tar.gz', // plataforma no soportada
'CloudRestoreAS-1.1.0-linux-x86_64.7z', // extensión inesperada
'CloudRestoreAS-1.0.0-rc1-linux-x86_64.tar.gz', // versión no comparable
'OtroProducto-1.1.0-linux-x86_64.tar.gz',
'',
' '
]) {
expect(parseArtifactName(name), name).toBeNull();
}
});
it('tolera espacios alrededor', () => {
expect(parseArtifactName(' CloudRestoreAS-1.1.0-linux-x86_64.tar.gz ')?.version).toBe(
'1.1.0'
);
});
});

160
src/lib/server/cras-sync.ts Normal file
View File

@@ -0,0 +1,160 @@
/**
* Sincronización del catálogo de versiones de CloudRestoreAS contra Gitea.
*
* Gitea es la fuente de verdad: el panel lee su API de paquetes y registra en
* `cras_releases` lo que encuentra. Se hace por pull (y no esperando un aviso del script de
* publicación) para que el catálogo se pueda reconstruir en cualquier momento sin depender
* de que ese aviso haya llegado. `publish-release.sh --notify-panel` solo adelanta el sync.
*
* Sincronizar NUNCA cambia qué versión está activa: activar es una decisión explícita del
* operador. Un sync que activara sola la última convertiría un `git push` en un despliegue.
*/
import { isCrasPlatform, isValidVersion, type CrasPlatform } from '$lib/cras-version';
import {
listPackageFiles,
listPackageVersions,
packageName,
GiteaError
} from './gitea-packages';
import { upsertCrasRelease } from './cras-releases';
import { logger } from './logger';
/**
* Nombres que produce packaging/scripts/package-release.sh:
* CloudRestoreAS-1.1.0-linux-x86_64.tar.gz
* CloudRestoreAS-1.1.0-win-x86_64.zip
*
* En el nombre se usa `win` por brevedad, pero el vocabulario del catálogo (y del CHECK de
* la tabla) es `windows`, que es lo que reporta el agente.
*/
const ARTIFACT_RE =
/^CloudRestoreAS-(\d+(?:\.\d+){0,3})-(win|linux)-([A-Za-z0-9_]+)\.(?:zip|tar\.gz)$/;
/** Archivos de verificación que se publican junto a los paquetes y no son artefactos. */
const NON_ARTIFACTS = new Set(['SHA256SUMS', 'release.json']);
export interface ParsedArtifact {
version: string;
platform: CrasPlatform;
arch: string;
}
/** Deriva versión/plataforma/arquitectura del nombre. null si no es un artefacto. */
export function parseArtifactName(fileName: string): ParsedArtifact | null {
const match = ARTIFACT_RE.exec(String(fileName ?? '').trim());
if (!match) return null;
const [, version, platformToken, arch] = match;
const platform = platformToken === 'win' ? 'windows' : 'linux';
if (!isCrasPlatform(platform) || !isValidVersion(version)) return null;
return { version, platform, arch: arch.toLowerCase() };
}
export interface SyncResult {
versions_seen: number;
artifacts_registered: number;
artifacts_new: number;
/** Archivos que no se pudieron interpretar; se reportan en lugar de ignorarse. */
skipped: { file_name: string; reason: string }[];
warnings: string[];
}
/**
* Recorre las versiones publicadas y registra sus artefactos.
*
* Si el nombre de un archivo no encaja en el patrón esperado se reporta como omitido en vez
* de descartarlo en silencio: un artefacto invisible se traduce en un servidor al que nunca
* se le ofrece la actualización, y eso es muy difícil de diagnosticar después.
*/
export async function syncCrasReleasesFromGitea(): Promise<SyncResult> {
const result: SyncResult = {
versions_seen: 0,
artifacts_registered: 0,
artifacts_new: 0,
skipped: [],
warnings: []
};
const versions = await listPackageVersions();
result.versions_seen = versions.length;
const pkg = packageName();
for (const entry of versions) {
if (!isValidVersion(entry.version)) {
result.skipped.push({
file_name: entry.version,
reason: 'la versión del paquete no es comparable (se esperan puntos y números)'
});
continue;
}
let files;
try {
files = await listPackageFiles(entry.version);
} catch (err) {
// Una versión ilegible no debe abortar el sync de las demás.
const message = err instanceof GiteaError ? err.message : String(err);
result.warnings.push(`No se pudieron listar los archivos de ${entry.version}: ${message}`);
logger.warn({
message: 'Fallo listando archivos de una versión de CRAS',
context: { version: entry.version, error: message }
});
continue;
}
const publishedAt = entry.created_at ? new Date(entry.created_at) : null;
for (const file of files) {
if (NON_ARTIFACTS.has(file.name)) continue;
const parsed = parseArtifactName(file.name);
if (!parsed) {
result.skipped.push({
file_name: `${entry.version}/${file.name}`,
reason: 'el nombre no coincide con CloudRestoreAS-<version>-<win|linux>-<arch>.<zip|tar.gz>'
});
continue;
}
if (parsed.version !== entry.version) {
// Un artefacto colgado de otra versión del paquete: es un error de
// publicación y registrarlo confundiría el catálogo.
result.skipped.push({
file_name: `${entry.version}/${file.name}`,
reason: `el nombre declara la versión ${parsed.version} pero está publicado en ${entry.version}`
});
continue;
}
if (!file.sha256) {
// Sin hash no se puede verificar la descarga, así que tampoco se puede
// instalar. Se registra igual para que sea visible en la UI, con aviso.
result.warnings.push(
`${file.name} no tiene sha256 en Gitea; no se podrá instalar hasta republicarlo`
);
}
const isNew = await upsertCrasRelease({
version: parsed.version,
platform: parsed.platform,
arch: parsed.arch,
file_name: file.name,
file_size: file.size || null,
sha256: file.sha256,
gitea_package: pkg,
published_at: publishedAt && !Number.isNaN(publishedAt.getTime()) ? publishedAt : null
});
result.artifacts_registered += 1;
if (isNew) result.artifacts_new += 1;
}
}
logger.info({
message: 'Catálogo de versiones de CRAS sincronizado con Gitea',
context: {
versions_seen: result.versions_seen,
artifacts_registered: result.artifacts_registered,
artifacts_new: result.artifacts_new,
skipped: result.skipped.length
}
});
return result;
}

View File

@@ -0,0 +1,199 @@
/**
* Identificación del sistema remoto en la sonda de verificación.
*
* El bug que esto fija: la sonda elegía la rama con `platform === 'windows' ? PS : POSIX`, así
* que una plataforma desconocida (`null`) caía a **Linux**. En la base real `restore_targets.os`
* estaba vacío en los 4 servidores, así que 3 de ellos se habrían diagnosticado corriéndoles
* `id -u`, `sudo`, `test -x /opt/...` y `systemctl` — reportando "sin privilegios, binario no
* instalado, servicio detenido" en un Windows perfectamente sano.
*
* Agravante: el chequeo de ejecución era `echo ok`, que **también funciona en cmd.exe**, así que
* el falso diagnóstico venía precedido de un "shell disponible: ok" tranquilizador.
*
* El tripwire del final es lo que impide que vuelva: en la rama Windows no debe emitirse ningún
* comando POSIX.
*/
import { describe, expect, it } from 'vitest';
import { probeRemoteSystem } from './cras-verify';
/**
* Cliente SFTP falso. `responder` decide qué contesta cada comando; se registran todos los
* comandos emitidos para poder auditarlos.
*/
function fakeSftp(responder: (command: string) => { code?: number; stdout?: string; stderr?: string }) {
const commands: string[] = [];
const sftp = {
client: {
exec(command: string, callback: (err: Error | null, stream?: unknown) => void) {
commands.push(command);
const result = responder(command);
const listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
const stderrListeners: ((...args: unknown[]) => void)[] = [];
const stream = {
on(event: string, fn: (...args: unknown[]) => void) {
(listeners[event] ??= []).push(fn);
return stream;
},
stderr: {
on(_e: string, fn: (...args: unknown[]) => void) {
stderrListeners.push(fn);
return stream.stderr;
}
}
};
callback(null, stream);
setImmediate(() => {
if (result.stdout) for (const fn of listeners.data ?? []) fn(Buffer.from(result.stdout));
if (result.stderr) for (const fn of stderrListeners) fn(Buffer.from(result.stderr));
for (const fn of listeners.exit ?? []) fn(result.code ?? 0);
for (const fn of listeners.close ?? []) fn();
});
}
}
};
return { sftp, commands };
}
/** ¿El comando es la prueba POSIX (no la de PowerShell)? */
const esPosix = (c: string) => c.includes('uname');
/** ¿Es la prueba de PowerShell? */
const esPowerShell = (c: string) => c.includes('-EncodedCommand');
describe('probeRemoteSystem: siempre prueba AMBAS vías', () => {
it('lanza las dos pruebas incluso cuando la primera ya concluyó', async () => {
// Es el corazón del arreglo: sin cascada. Aunque uname conteste, se prueba PowerShell,
// porque es lo único que distingue un Linux de un WSL dentro de Windows.
const { sftp, commands } = fakeSftp((c) =>
esPosix(c) ? { code: 0, stdout: 'Linux\nSYSTEMD' } : { code: 127, stderr: 'not found' }
);
await probeRemoteSystem(sftp as never);
expect(commands.filter(esPosix)).toHaveLength(1);
expect(commands.filter(esPowerShell)).toHaveLength(1);
});
});
describe('probeRemoteSystem: los cuatro veredictos son cerrados', () => {
it('POSIX responde y PowerShell no → linux', async () => {
const { sftp } = fakeSftp((c) =>
esPosix(c) ? { code: 0, stdout: 'Linux\nSYSTEMD' } : { code: 127, stderr: 'not found' }
);
const r = await probeRemoteSystem(sftp as never);
expect(r.verdict).toBe('linux');
expect(r.posix).toBe(true);
expect(r.systemd).toBe(true);
expect(r.windows).toBe(false);
});
it('PowerShell responde y POSIX no → windows', async () => {
const { sftp } = fakeSftp((c) =>
esPowerShell(c) ? { code: 0, stdout: 'PS5 TASKS' } : { code: 1, stderr: "'uname' no se reconoce" }
);
const r = await probeRemoteSystem(sftp as never);
expect(r.verdict).toBe('windows');
expect(r.windows).toBe(true);
expect(r.scheduledTasks).toBe(true);
expect(r.posix).toBe(false);
});
it('ambos responden → posix_en_host_windows (WSL/Cygwin)', async () => {
// Instalar aquí pondría el agente dentro del subsistema, no en el Windows que corre
// SQL Server. Es el caso que hay que gritar, no resolver en silencio.
const { sftp } = fakeSftp((c) =>
esPosix(c) ? { code: 0, stdout: 'Linux\nSYSTEMD' } : { code: 0, stdout: 'PS5 TASKS' }
);
const r = await probeRemoteSystem(sftp as never);
expect(r.verdict).toBe('posix_en_host_windows');
expect(r.posix).toBe(true);
expect(r.windows).toBe(true);
});
it('ninguno responde → sin_ejecucion (cuenta solo-SFTP o enjaulada)', async () => {
// Respuesta certera: no es que se ignore el sistema, es que esa cuenta no sirve.
const { sftp } = fakeSftp(() => ({ code: 1, stderr: 'This service allows sftp connections only.' }));
const r = await probeRemoteSystem(sftp as never);
expect(r.verdict).toBe('sin_ejecucion');
});
it('nunca devuelve null ni undefined como veredicto', async () => {
const escenarios: Array<(c: string) => { code?: number; stdout?: string }> = [
() => ({ code: 0, stdout: '' }),
() => ({ code: 0, stdout: 'basura inesperada' }),
() => ({ code: 255 }),
(c) => (esPosix(c) ? { code: 0, stdout: 'Darwin' } : { code: 1 })
];
for (const responder of escenarios) {
const { sftp } = fakeSftp(responder);
const r = await probeRemoteSystem(sftp as never);
expect(['linux', 'windows', 'posix_en_host_windows', 'sin_ejecucion']).toContain(r.verdict);
}
});
});
describe('probeRemoteSystem: capacidades, no solo identidad', () => {
it('Linux sin systemd se detecta como linux pero systemd=false', async () => {
// Importa porque install.sh --service no puede registrar el servicio sin systemd.
const { sftp } = fakeSftp((c) => (esPosix(c) ? { code: 0, stdout: 'Linux' } : { code: 127 }));
const r = await probeRemoteSystem(sftp as never);
expect(r.verdict).toBe('linux');
expect(r.systemd).toBe(false);
});
it('Windows sin Get-ScheduledTask se detecta como windows pero scheduledTasks=false', async () => {
const { sftp } = fakeSftp((c) =>
esPowerShell(c) ? { code: 0, stdout: 'PS5' } : { code: 1 }
);
const r = await probeRemoteSystem(sftp as never);
expect(r.verdict).toBe('windows');
expect(r.scheduledTasks).toBe(false);
});
it('un exit code distinto de 0 no cuenta como respuesta', async () => {
// Salida plausible pero con código de error: no debe tomarse por válida.
const { sftp } = fakeSftp(() => ({ code: 1, stdout: 'Linux' }));
const r = await probeRemoteSystem(sftp as never);
expect(r.verdict).toBe('sin_ejecucion');
});
});
describe('probeRemoteSystem: evidencia reportada', () => {
it('incluye qué contestó cada prueba, para no mostrar todo con la misma confianza', async () => {
const { sftp } = fakeSftp((c) =>
esPosix(c) ? { code: 0, stdout: 'Linux\nSYSTEMD' } : { code: 127 }
);
const r = await probeRemoteSystem(sftp as never);
expect(r.evidence).toContain('posix=Linux');
expect(r.evidence).toContain('systemd=sí');
expect(r.evidence).toContain('powershell=no responde');
});
});
describe('TRIPWIRE: ningún comando POSIX en la rama Windows', () => {
it('la prueba de PowerShell no lleva comandos de shell POSIX', async () => {
// Es la regresión concreta que causó el bug: comandos POSIX en un Windows.
const { sftp, commands } = fakeSftp((c) =>
esPowerShell(c) ? { code: 0, stdout: 'PS5 TASKS' } : { code: 1 }
);
await probeRemoteSystem(sftp as never);
const psCommands = commands.filter(esPowerShell);
expect(psCommands).toHaveLength(1);
for (const cmd of psCommands) {
// El script va en base64, así que se decodifica para inspeccionarlo de verdad.
const decoded = Buffer.from(cmd.split('-EncodedCommand ')[1], 'base64').toString('utf16le');
for (const posix of ['systemctl', 'id -u', 'sudo', 'test -x', 'uname', '/opt/']) {
expect(decoded, `"${posix}" no debe aparecer en un comando de Windows`).not.toContain(
posix
);
}
}
});
it('la prueba POSIX no invoca powershell', async () => {
const { sftp, commands } = fakeSftp(() => ({ code: 0, stdout: 'Linux' }));
await probeRemoteSystem(sftp as never);
for (const cmd of commands.filter(esPosix)) {
expect(cmd.toLowerCase()).not.toContain('powershell');
}
});
});

View File

@@ -0,0 +1,751 @@
/**
* Sonda en vivo de un servidor de restauración: responde "¿está disponible AHORA?".
*
* Es distinta del timestamp de `cloudrestore_status.reported_at`, que solo dice cuándo el
* agente arrancó o guardó configuración (no es un heartbeat: se reporta en 3 momentos de
* ciclo de vida, ninguno periódico). Un agente sano de hace una semana y un agente muerto de
* hace una semana tienen el mismo `reported_at`; esta sonda los distingue.
*
* **Solo lee.** No instala, no escribe archivos ni reinicia servicios en el destino, así que
* es seguro correrla en cualquier momento.
*
* Lo importante del diseño es el diagnóstico: cuando falla, dice POR QUÉ. Se hace una prueba
* TCP cruda antes del handshake SSH para poder separar "el host no responde" de "el puerto
* está cerrado porque sshd está detenido", que son dos problemas con remedios opuestos.
*/
import net from 'node:net';
import SftpClient from 'ssh2-sftp-client';
import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg';
import { execRemote, psEncoded, shQuote } from './cras-install';
import { listCrasTargetInventory } from './cras-releases';
import { DEFAULT_INSTALL_PATHS, effectiveInstallPath, type CrasPlatform } from '$lib/cras-version';
import { logger } from './logger';
const TCP_TIMEOUT_MS = 6_000;
const SSH_TIMEOUT_MS = 15_000;
const CHECK_TIMEOUT_MS = 20_000;
/** Resultado de un chequeo individual. `skipped` = no se pudo evaluar por un fallo previo. */
export type CheckStatus = 'ok' | 'fail' | 'warn' | 'skipped';
export interface VerifyCheck {
key: string;
label: string;
status: CheckStatus;
detail: string;
}
/** Causa raíz clasificada, para poder ofrecer el remedio correcto. */
export type VerifyDiagnosis =
| 'ok'
| 'sin_credenciales'
| 'host_no_responde'
| 'puerto_cerrado'
| 'credenciales_rechazadas'
| 'sin_ejecucion'
| 'posix_en_host_windows'
| 'sin_privilegios'
| 'sin_instalar'
| 'servicio_detenido'
| 'error';
export interface VerifyResult {
restore_target_id: number;
name: string;
platform: CrasPlatform | null;
diagnosis: VerifyDiagnosis;
/** Resumen de una línea, listo para la UI. */
summary: string;
/** Qué hacer, cuando hay algo que hacer. */
remediation: Remediation | null;
checks: VerifyCheck[];
checked_at: string;
}
export interface Remediation {
/** Descripción de para qué sirve. */
title: string;
/** Comando exacto a correr EN el servidor destino, para copiar y pegar. */
command: string | null;
/** Dónde correrlo. */
where: string;
/**
* Si el agente podría aplicarlo por su cuenta (corre local, no necesita SSH). Hoy es
* informativo: la ejecución remota desde el panel no está implementada.
*/
agent_could_apply: boolean;
notes: string[];
}
function check(key: string, label: string, status: CheckStatus, detail = ''): VerifyCheck {
return { key, label, status, detail };
}
/**
* Veredicto sobre el sistema del otro lado de la sesión. Nunca es "no sé": las cuatro
* combinaciones posibles de las dos pruebas dan una respuesta cerrada y accionable.
*/
export type SystemVerdict = 'linux' | 'windows' | 'posix_en_host_windows' | 'sin_ejecucion';
export interface SystemProbe {
/** La prueba POSIX respondió (hay shell tipo Unix). */
posix: boolean;
/** Hay systemd, que es lo que el instalador necesita para el modo servicio. */
systemd: boolean;
/** La prueba de PowerShell respondió. */
windows: boolean;
/** Existe el registro de tareas programadas (el equivalente de systemd en Windows). */
scheduledTasks: boolean;
verdict: SystemVerdict;
/** Qué contestó cada prueba, para que la UI no muestre todo con la misma confianza. */
evidence: string;
}
/**
* Identifica el sistema remoto probando **las dos** vías, siempre, sin cascada y sin confiar en
* lo que diga la base.
*
* Se prueban CAPACIDADES y no identidad: al instalador no le importa cómo se llama el sistema,
* le importa si puede colocar el binario y registrar el arranque automático. Un Linux sin
* systemd es tan inútil para el modo servicio como un Windows sin tareas programadas.
*
* Por qué no basta encadenar: el chequeo anterior usaba `echo ok`, que **también funciona en
* cmd.exe**, así que un Windows pasaba como "tiene shell" y luego se le corrían `id -u`,
* `sudo`, `test -x /opt/...` y `systemctl`. Reportaba "sin privilegios, binario no instalado,
* servicio detenido" en un servidor perfectamente sano.
*
* Ninguno de los comandos escribe nada, así que es seguro lanzarlos en cualquier sistema.
*/
export async function probeRemoteSystem(sftp: SftpClient): Promise<SystemProbe> {
const run = async (command: string): Promise<string> => {
try {
const r = await execRemote(sftp, command, CHECK_TIMEOUT_MS);
return r.code === 0 ? r.stdout.trim() : '';
} catch {
// Un fallo aquí es información, no una excepción: significa que esa vía no está.
return '';
}
};
// POSIX: nombre del kernel y si hay systemd.
const posixOut = await run('uname -s 2>/dev/null && (command -v systemctl >/dev/null && echo SYSTEMD || true)');
const posix = /linux|darwin|bsd/i.test(posixOut);
const systemd = posixOut.includes('SYSTEMD');
// Windows: versión de PowerShell y si existe Get-ScheduledTask.
const winOut = await run(
psEncoded(
'$v = $PSVersionTable.PSVersion.Major; ' +
'$t = if (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue) {"TASKS"} else {""}; ' +
'Write-Output "PS$v $t"'
)
);
const windows = /^PS\d/.test(winOut);
const scheduledTasks = winOut.includes('TASKS');
let verdict: SystemVerdict;
if (posix && windows) verdict = 'posix_en_host_windows';
else if (posix) verdict = 'linux';
else if (windows) verdict = 'windows';
else verdict = 'sin_ejecucion';
const evidence = [
`posix=${posix ? posixOut.split('\n')[0] || 'sí' : 'no responde'}`,
`systemd=${systemd ? 'sí' : 'no'}`,
`powershell=${windows ? winOut.split(' ')[0] : 'no responde'}`,
`tareas=${scheduledTasks ? 'sí' : 'no'}`
].join(', ');
return { posix, systemd, windows, scheduledTasks, verdict, evidence };
}
/**
* Prueba TCP cruda al puerto SSH. Es lo que permite distinguir un host apagado de un sshd
* detenido: en el primer caso la conexión expira o no hay ruta, en el segundo el sistema
* responde con ECONNREFUSED de inmediato.
*/
function probeTcp(
host: string,
port: number
): Promise<{ ok: boolean; code: string | null; message: string }> {
return new Promise((resolve) => {
const socket = new net.Socket();
let settled = false;
const done = (ok: boolean, code: string | null, message: string) => {
if (settled) return;
settled = true;
socket.destroy();
resolve({ ok, code, message });
};
socket.setTimeout(TCP_TIMEOUT_MS);
socket.once('connect', () => done(true, null, 'puerto abierto'));
socket.once('timeout', () => done(false, 'ETIMEDOUT', 'la conexión expiró'));
socket.once('error', (err: NodeJS.ErrnoException) =>
done(false, err.code ?? null, err.message)
);
socket.connect(port, host);
});
}
/** Traduce el error de la prueba TCP a un diagnóstico y un mensaje entendible. */
function classifyTcpFailure(
code: string | null,
port: number
): { diagnosis: VerifyDiagnosis; detail: string } {
if (code === 'ECONNREFUSED') {
// El host está prendido y contestó "no hay nadie escuchando": el servicio SSH está caído.
return {
diagnosis: 'puerto_cerrado',
detail: `el servidor respondió pero nadie escucha en el puerto ${port}: el servicio SSH está detenido`
};
}
if (code === 'ETIMEDOUT' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') {
return {
diagnosis: 'host_no_responde',
detail: 'el host no respondió: apagado, sin red, o bloqueado por un firewall'
};
}
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
return {
diagnosis: 'host_no_responde',
detail: 'no se pudo resolver el nombre del host (DNS)'
};
}
return { diagnosis: 'error', detail: `fallo de red (${code ?? 'desconocido'})` };
}
function isAuthFailure(message: string): boolean {
const m = message.toLowerCase();
return (
m.includes('authentication') ||
m.includes('all configured authentication methods failed') ||
m.includes('permission denied')
);
}
/**
* Remedio para un servicio SSH detenido. El comando se muestra para correrlo a mano en el
* servidor: el panel no puede aplicarlo por SSH, porque justamente SSH es lo que está caído.
*/
function sshdRemediation(platform: CrasPlatform | null): Remediation {
if (platform === 'linux') {
return {
title: 'Reiniciar el servicio SSH en el servidor',
command: 'sudo systemctl restart sshd && sudo systemctl status sshd',
where: 'En una terminal del servidor, con privilegios de root.',
// La unit systemd del agente corre con User=<servicio>, sin privilegios para
// reiniciar sshd; haría falta una regla de sudoers dedicada.
agent_could_apply: false,
notes: [
'El panel no puede hacerlo por SSH: SSH es precisamente lo que no responde.',
'El agente CloudRestoreAS tampoco puede: su servicio corre como usuario sin privilegios.',
'Si quieres que el agente lo pueda hacer, hay que darle una regla de sudoers acotada a ese comando.'
]
};
}
return {
title: 'Reiniciar el servicio SSH en el servidor',
command: 'Restart-Service sshd',
where: 'En PowerShell como Administrador, en el servidor.',
// La tarea del agente corre como SYSTEM con RunLevel Highest, así que sí tendría
// permisos para reiniciar el servicio si se implementara la remediación asistida.
agent_could_apply: true,
notes: [
'El panel no puede hacerlo por SSH: SSH es precisamente lo que no responde.',
'Verifica también que el servicio arranque solo: Set-Service sshd -StartupType Automatic',
'El agente CloudRestoreAS corre como SYSTEM en este servidor, así que sí tendría permisos para aplicarlo él mismo si se habilita la remediación asistida.'
]
};
}
/**
* Verifica un servidor de restauración. Nunca lanza por un fallo del destino: devuelve el
* diagnóstico. Solo lanza si el destino no existe.
*/
export async function verifyCrasTarget(restoreTargetId: number): Promise<VerifyResult> {
const inventory = await listCrasTargetInventory();
const row = inventory.find((t) => t.restore_target_id === restoreTargetId);
const name = row?.name ?? `#${restoreTargetId}`;
const platform = row?.platform ?? null;
const checks: VerifyCheck[] = [];
const checkedAt = new Date().toISOString();
const finish = (
diagnosis: VerifyDiagnosis,
summary: string,
remediation: Remediation | null = null
): VerifyResult => ({
restore_target_id: restoreTargetId,
name,
platform,
diagnosis,
summary,
remediation,
checks,
checked_at: checkedAt
});
let target: RestoreTargetSsh | null;
try {
target = await getRestoreTargetSsh(restoreTargetId);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
checks.push(check('credenciales', 'Credenciales SSH', 'fail', message));
return finish('error', `No se pudieron leer las credenciales de ${name}.`);
}
if (!target) {
checks.push(
check(
'credenciales',
'Credenciales SSH',
'fail',
'faltan host, usuario o contraseña SSH'
)
);
return finish('sin_credenciales', `${name} no tiene credenciales SSH completas.`, {
title: 'Capturar las credenciales SSH del servidor',
command: null,
where: 'Panel → Servidores de Restauración → editar este servidor.',
agent_could_apply: false,
notes: ['Sin host, usuario y contraseña SSH el panel no puede verificar ni instalar.']
});
}
checks.push(
check(
'credenciales',
'Credenciales SSH',
'ok',
`${target.ssh_username}@${target.ssh_host}:${target.ssh_port}`
)
);
// --- 1) Puerto TCP: separa host caído de sshd detenido --------------------
const tcp = await probeTcp(target.ssh_host, target.ssh_port);
if (!tcp.ok) {
const { diagnosis, detail } = classifyTcpFailure(tcp.code, target.ssh_port);
checks.push(check('tcp', `Puerto ${target.ssh_port} accesible`, 'fail', detail));
for (const key of ['ssh', 'shell', 'privilegios', 'binario', 'servicio', 'config']) {
checks.push(check(key, key, 'skipped', 'no se evaluó: no hay conexión'));
}
const summary =
diagnosis === 'puerto_cerrado'
? `${name}: el servidor está prendido pero el servicio SSH está detenido.`
: `${name}: el host no responde.`;
return finish(
diagnosis,
summary,
diagnosis === 'puerto_cerrado' ? sshdRemediation(platform) : null
);
}
checks.push(check('tcp', `Puerto ${target.ssh_port} accesible`, 'ok', 'puerto abierto'));
// --- 2) Handshake y autenticación SSH ------------------------------------
const sftp = new SftpClient(`cras-verify-${restoreTargetId}`);
try {
try {
await sftp.connect({
host: target.ssh_host,
port: target.ssh_port,
username: target.ssh_username,
password: target.ssh_password,
readyTimeout: SSH_TIMEOUT_MS
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const auth = isAuthFailure(message);
checks.push(
check('ssh', 'Autenticación SSH', 'fail', auth ? 'credenciales rechazadas' : message)
);
for (const key of ['shell', 'privilegios', 'binario', 'servicio', 'config']) {
checks.push(check(key, key, 'skipped', 'no se evaluó: no hubo sesión'));
}
return finish(
auth ? 'credenciales_rechazadas' : 'error',
auth
? `${name}: el servidor SSH respondió pero rechazó las credenciales.`
: `${name}: falló la conexión SSH — ${message}`,
auth
? {
title: 'Actualizar las credenciales SSH',
command: null,
where: 'Panel → Servidores de Restauración → editar este servidor.',
agent_could_apply: false,
notes: ['El servicio SSH está arriba; el usuario o la contraseña no coinciden.']
}
: null
);
}
checks.push(check('ssh', 'Autenticación SSH', 'ok', 'sesión establecida'));
// --- 3) Identificar el sistema: se prueban AMBOS, siempre ------------
const system = await probeRemoteSystem(sftp);
checks.push(
check(
'ejecucion',
'Ejecución de comandos',
system.verdict === 'sin_ejecucion' ? 'fail' : 'ok',
system.evidence
)
);
if (system.verdict === 'sin_ejecucion') {
for (const key of ['privilegios', 'binario', 'servicio', 'config']) {
checks.push(
check(key, key, 'skipped', 'no se evaluó: la cuenta no ejecuta comandos')
);
}
return finish(
'sin_ejecucion',
`${name}: la cuenta SSH conecta pero no ejecuta comandos (solo-SFTP o enjaulada).`,
{
title: 'Usar una cuenta SSH con shell para instalar',
command: null,
where: 'Configuración de sshd en el servidor, o capturar otra cuenta en el panel.',
agent_could_apply: false,
notes: [
'El forward de respaldos funciona igual: para eso basta SFTP.',
'La instalación remota sí necesita ejecutar comandos y privilegios, y puede usar una cuenta distinta de la del forward.',
'Revisa ForceCommand internal-sftp / ChrootDirectory en sshd_config.'
]
}
);
}
// El sistema DETECTADO manda sobre lo que digan la base o el agente: es lo que hay del
// otro lado de esta sesión. Si contradicen, se reporta como hallazgo aparte.
const detected: CrasPlatform = system.verdict === 'windows' ? 'windows' : 'linux';
const declared = platform;
if (declared && declared !== detected) {
checks.push(
check(
'coherencia',
'Plataforma registrada vs. real',
'fail',
`el panel tiene "${declared}" pero el servidor responde como ${detected}: ` +
'el instalador elegiría el artefacto equivocado'
)
);
}
// Llegar a un POSIX dentro de un host Windows significa que la sesión cae en WSL/Cygwin
// y NO en el Windows que corre SQL Server. Instalar ahí dejaría el servicio dentro del
// subsistema y las rutas C:\ del .env apuntando a otro sitio.
if (system.verdict === 'posix_en_host_windows') {
checks.push(
check(
'subsistema',
'Destino de la sesión SSH',
'fail',
'responden POSIX y PowerShell: estás llegando a un subsistema (WSL/Cygwin), no al host Windows'
)
);
for (const key of ['privilegios', 'binario', 'servicio', 'config']) {
checks.push(check(key, key, 'skipped', 'no se evaluó: el destino es ambiguo'));
}
return finish(
'posix_en_host_windows',
`${name}: la sesión SSH entra a un subsistema POSIX dentro de un host Windows, no al host.`,
{
title: 'Apuntar el SSH al servicio del host Windows',
command: null,
where: 'Servidores de Restauración → corregir host/puerto SSH de este servidor.',
agent_could_apply: false,
notes: [
'Instalar aquí pondría CloudRestoreAS dentro del subsistema: no arrancaría con la máquina.',
'Las rutas C:\\ del config/.env no serían las mismas que ve SQL Server.',
'Verifica que el puerto 22 apunte al sshd de Windows y no al del subsistema.'
]
}
);
}
// --- 4) Privilegios, binario, servicio y configuración ---------------
const detail =
detected === 'windows'
? await inspectWindows(sftp, row?.reported_install_path ?? null)
: await inspectLinux(sftp, row?.reported_install_path ?? null);
checks.push(...detail.checks);
if (detail.diagnosis !== 'ok') {
return finish(detail.diagnosis, `${name}: ${detail.summary}`, detail.remediation);
}
return finish('ok', `${name}: disponible — ${detail.summary}`);
} finally {
try {
await sftp.end();
} catch (err) {
logger.warn({
message: 'No se pudo cerrar la sesión SFTP de verificación',
context: {
target: name,
error: err instanceof Error ? err.message : String(err)
}
});
}
}
}
interface InspectResult {
checks: VerifyCheck[];
diagnosis: VerifyDiagnosis;
summary: string;
remediation: Remediation | null;
}
async function inspectLinux(
sftp: SftpClient,
reportedInstallPath: string | null
): Promise<InspectResult> {
const checks: VerifyCheck[] = [];
const prefix = effectiveInstallPath(reportedInstallPath, 'linux') ?? DEFAULT_INSTALL_PATHS.linux;
const id = await execRemote(sftp, 'id -u', CHECK_TIMEOUT_MS);
let privileged = id.code === 0 && id.stdout.trim() === '0';
let privLabel = 'root';
if (!privileged) {
const sudo = await execRemote(sftp, 'sudo -n true', CHECK_TIMEOUT_MS);
privileged = sudo.code === 0;
privLabel = privileged ? 'sudo sin contraseña' : 'sin privilegios';
}
checks.push(
check(
'privilegios',
'Privilegios para instalar',
privileged ? 'ok' : 'warn',
privileged ? privLabel : 'no es root y no tiene sudo sin contraseña'
)
);
const bin = await execRemote(
sftp,
`test -x ${shQuote(`${prefix}/CloudRestoreAS`)} && echo si || echo no`,
CHECK_TIMEOUT_MS
);
const installed = bin.stdout.trim() === 'si';
checks.push(
check(
'binario',
'Binario instalado',
installed ? 'ok' : 'fail',
installed ? `${prefix}/CloudRestoreAS` : `no está en ${prefix}`
)
);
const version = await execRemote(
sftp,
`cat ${shQuote(`${prefix}/config/.version`)} 2>/dev/null`,
CHECK_TIMEOUT_MS
);
const deployed = version.stdout.trim();
const env = await execRemote(
sftp,
`test -f ${shQuote(`${prefix}/config/.env`)} && echo si || echo no`,
CHECK_TIMEOUT_MS
);
const hasEnv = env.stdout.trim() === 'si';
checks.push(
check(
'config',
'Configuración presente',
hasEnv ? 'ok' : 'warn',
hasEnv ? `${prefix}/config/.env` : 'falta config/.env'
)
);
const active = await execRemote(
sftp,
'systemctl is-active cloudrestoreas 2>/dev/null || true',
CHECK_TIMEOUT_MS
);
const state = active.stdout.trim() || 'desconocido';
const running = state === 'active';
checks.push(
check('servicio', 'Servicio corriendo', running ? 'ok' : 'fail', `systemd: ${state}`)
);
if (!installed) {
return {
checks,
diagnosis: 'sin_instalar',
summary: `SSH bien, pero CloudRestoreAS no está instalado en ${prefix}.`,
remediation: {
title: 'Instalar CloudRestoreAS en este servidor',
command: null,
where: 'Panel → Versiones CRAS → Instalar.',
agent_could_apply: false,
notes: privileged
? []
: ['Antes hay que resolver los privilegios: se necesita root o sudo sin contraseña.']
}
};
}
if (!running) {
return {
checks,
diagnosis: 'servicio_detenido',
summary: `instalado (${deployed || 'versión desconocida'}) pero el servicio está ${state}.`,
remediation: {
title: 'Arrancar el servicio del agente',
command: 'sudo systemctl start cloudrestoreas && sudo systemctl status cloudrestoreas',
where: 'En una terminal del servidor, con privilegios de root.',
agent_could_apply: false,
notes: ['Para ver la causa: sudo journalctl -u cloudrestoreas -n 50']
}
};
}
return {
checks,
diagnosis: 'ok',
summary: `servicio activo, versión ${deployed || 'sin sello'}, en ${prefix}.`,
remediation: null
};
}
async function inspectWindows(
sftp: SftpClient,
reportedInstallPath: string | null
): Promise<InspectResult> {
const checks: VerifyCheck[] = [];
const prefix =
effectiveInstallPath(reportedInstallPath, 'windows') ?? DEFAULT_INSTALL_PATHS.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"}'
),
CHECK_TIMEOUT_MS
);
const isAdmin = admin.stdout.trim() === 'admin';
checks.push(
check(
'privilegios',
'Privilegios para instalar',
isAdmin ? 'ok' : 'warn',
isAdmin ? 'Administrador' : 'la cuenta SSH no es Administrador'
)
);
const bin = await execRemote(
sftp,
psEncoded(`if (Test-Path -LiteralPath '${prefix}\\CloudRestoreAS.exe') {"si"} else {"no"}`),
CHECK_TIMEOUT_MS
);
const installed = bin.stdout.trim() === 'si';
checks.push(
check(
'binario',
'Binario instalado',
installed ? 'ok' : 'fail',
installed ? `${prefix}\\CloudRestoreAS.exe` : `no está en ${prefix}`
)
);
const version = await execRemote(
sftp,
psEncoded(
`if (Test-Path -LiteralPath '${prefix}\\config\\.version') ` +
`{ (Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw).Trim() }`
),
CHECK_TIMEOUT_MS
);
const deployed = version.stdout.trim();
const env = await execRemote(
sftp,
psEncoded(`if (Test-Path -LiteralPath '${prefix}\\config\\.env') {"si"} else {"no"}`),
CHECK_TIMEOUT_MS
);
const hasEnv = env.stdout.trim() === 'si';
checks.push(
check(
'config',
'Configuración presente',
hasEnv ? 'ok' : 'warn',
hasEnv ? `${prefix}\\config\\.env` : 'falta config\\.env'
)
);
const task = await execRemote(
sftp,
psEncoded(
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
'if ($t) { $t.State } else { "no-registrada" }'
),
CHECK_TIMEOUT_MS
);
const state = task.stdout.trim() || 'desconocido';
// Ready = registrada y esperando el disparador; Running = ejecutándose. Ambas cuentan
// como "el arranque automático está configurado".
const scheduled = state === 'Running' || state === 'Ready';
checks.push(
check(
'servicio',
'Arranque automático',
scheduled ? 'ok' : 'fail',
`tarea CloudRestoreAS: ${state}`
)
);
// El proceso vivo es la señal más directa de que está trabajando.
const proc = await execRemote(
sftp,
psEncoded(
"$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
'if ($p) { "corriendo:" + $p.Count } else { "detenido" }'
),
CHECK_TIMEOUT_MS
);
const procOut = proc.stdout.trim();
const running = procOut.startsWith('corriendo');
checks.push(
check('proceso', 'Proceso en ejecución', running ? 'ok' : 'warn', procOut || 'desconocido')
);
if (!installed) {
return {
checks,
diagnosis: 'sin_instalar',
summary: `SSH bien, pero CloudRestoreAS no está instalado en ${prefix}.`,
remediation: {
title: 'Instalar CloudRestoreAS en este servidor',
command: null,
where: 'Panel → Versiones CRAS → Instalar.',
agent_could_apply: false,
notes: isAdmin
? []
: ['Antes hay que usar una cuenta SSH Administradora: la tarea corre como SYSTEM.']
}
};
}
if (!scheduled || !running) {
return {
checks,
diagnosis: 'servicio_detenido',
summary: `instalado (${deployed || 'versión desconocida'}) pero no está corriendo (tarea: ${state}).`,
remediation: {
title: 'Arrancar el agente en el servidor',
command: 'Start-ScheduledTask -TaskName CloudRestoreAS',
where: 'En PowerShell como Administrador, en el servidor.',
agent_could_apply: false,
notes: [
`Logs del agente: ${prefix}\\config\\logs`,
scheduled ? '' : 'La tarea no está registrada: reinstala desde el panel con arranque de servicio.'
].filter(Boolean)
}
};
}
return {
checks,
diagnosis: 'ok',
summary: `agente corriendo, versión ${deployed || 'sin sello'}, en ${prefix}.`,
remediation: null
};
}

View File

@@ -0,0 +1,216 @@
/**
* Cliente del registro de paquetes genéricos de Gitea.
*
* `fetch` se stubea: lo que se prueba es el parseo, el header de auth y el manejo de errores.
* Importa especialmente que el sha256 solo se acepte si tiene forma de sha256 — el panel lo
* usa para verificar descargas y un valor basura haría fallar toda instalación con un error
* confuso, o peor, se aceptaría un hash truncado.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { env } from '$env/dynamic/private';
import {
GiteaError,
isGiteaConfigured,
listPackageFiles,
listPackageVersions,
openPackageFile,
packageLocation,
packageName
} from './gitea-packages';
const originalFetch = globalThis.fetch;
function stubFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
const spy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) =>
handler(String(input), init)
);
globalThis.fetch = spy as unknown as typeof fetch;
return spy;
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' }
});
}
beforeEach(() => {
env.GITEA_TOKEN = 'token-de-prueba';
env.GITEA_BASE_URL = 'https://git.ejemplo.test';
env.GITEA_OWNER = 'ADUANASOFT';
env.CRAS_PACKAGE_NAME = 'cloudrestoreas';
});
afterEach(() => {
globalThis.fetch = originalFetch;
for (const key of ['GITEA_TOKEN', 'GITEA_BASE_URL', 'GITEA_OWNER', 'CRAS_PACKAGE_NAME']) {
delete env[key];
}
vi.restoreAllMocks();
});
describe('configuración', () => {
it('isGiteaConfigured refleja la presencia del token', () => {
expect(isGiteaConfigured()).toBe(true);
env.GITEA_TOKEN = ' ';
expect(isGiteaConfigured()).toBe(false);
// Cadena vacía y no `delete`: es el caso real de producción, porque
// `GITEA_TOKEN=${GITEA_TOKEN:-}` en el docker-compose deja la variable definida y vacía.
env.GITEA_TOKEN = '';
expect(isGiteaConfigured()).toBe(false);
});
it('packageName y packageLocation usan la configuración', () => {
expect(packageName()).toBe('cloudrestoreas');
expect(packageLocation()).toBe(
'https://git.ejemplo.test/api/packages/ADUANASOFT/generic/cloudrestoreas'
);
});
it('sin token, cualquier llamada falla con 500 en lugar de pegarle a Gitea', async () => {
env.GITEA_TOKEN = '';
const spy = stubFetch(() => jsonResponse([]));
await expect(listPackageVersions()).rejects.toThrow(GiteaError);
expect(spy).not.toHaveBeenCalled();
});
});
describe('listPackageVersions', () => {
it('manda el token y devuelve solo las versiones del paquete exacto', async () => {
const spy = stubFetch((url) => {
expect(url).toContain('/api/v1/packages/ADUANASOFT');
expect(url).toContain('type=generic');
return jsonResponse([
{ name: 'cloudrestoreas', version: '1.1.0', created_at: '2026-07-29T10:00:00Z' },
{ name: 'cloudrestoreas', version: '1.0.0', created_at: '2026-01-25T10:00:00Z' },
// `q=` de Gitea busca por coincidencia parcial: este NO debe entrar, o sus
// artefactos se registrarían como si fueran de producción.
{ name: 'cloudrestoreas-beta', version: '2.0.0', created_at: null }
]);
});
const versions = await listPackageVersions();
expect(versions.map((v) => v.version)).toEqual(['1.1.0', '1.0.0']);
const init = spy.mock.calls[0][1] as RequestInit;
expect((init.headers as Record<string, string>).Authorization).toBe('token token-de-prueba');
});
it('descarta entradas sin versión', async () => {
stubFetch(() =>
jsonResponse([
{ name: 'cloudrestoreas', version: '' },
{ name: 'cloudrestoreas', version: '1.1.0' }
])
);
expect((await listPackageVersions()).map((v) => v.version)).toEqual(['1.1.0']);
});
it('401/403 explican que el token fue rechazado', async () => {
stubFetch(() => new Response('nope', { status: 401 }));
await expect(listPackageVersions()).rejects.toThrow(/token/i);
});
it('un error de red se traduce a 503, no a una excepción cruda', async () => {
globalThis.fetch = vi.fn(async () => {
throw new TypeError('fetch failed');
}) as unknown as typeof fetch;
await expect(listPackageVersions()).rejects.toMatchObject({ status: 503 });
});
it('una respuesta que no es arreglo se rechaza', async () => {
stubFetch(() => jsonResponse({ mensaje: 'no es lista' }));
await expect(listPackageVersions()).rejects.toThrow(/inesperado/i);
});
it('JSON inválido se rechaza con 502', async () => {
stubFetch(() => new Response('no soy json', { status: 200 }));
await expect(listPackageVersions()).rejects.toMatchObject({ status: 502 });
});
});
describe('listPackageFiles', () => {
it('devuelve nombre, tamaño y sha256', async () => {
stubFetch((url) => {
expect(url).toContain('/generic/cloudrestoreas/1.1.0/files');
return jsonResponse([
{
name: 'CloudRestoreAS-1.1.0-linux-x86_64.tar.gz',
size: 281696057,
sha256: 'a'.repeat(64)
}
]);
});
const files = await listPackageFiles('1.1.0');
expect(files).toEqual([
{
name: 'CloudRestoreAS-1.1.0-linux-x86_64.tar.gz',
size: 281696057,
sha256: 'a'.repeat(64)
}
]);
});
it('normaliza el sha256 a minúsculas', async () => {
stubFetch(() => jsonResponse([{ name: 'x.zip', size: 1, sha256: 'A'.repeat(64) }]));
expect((await listPackageFiles('1.1.0'))[0].sha256).toBe('a'.repeat(64));
});
it('descarta un sha256 con forma inválida en lugar de propagarlo', async () => {
// Un hash truncado o basura no sirve para verificar; mejor null y que la UI avise.
stubFetch(() =>
jsonResponse([
{ name: 'corto.zip', size: 1, sha256: 'abc123' },
{ name: 'vacio.zip', size: 1, sha256: '' },
{ name: 'ausente.zip', size: 1 },
{ name: 'nohex.zip', size: 1, sha256: 'z'.repeat(64) }
])
);
const files = await listPackageFiles('1.1.0');
expect(files.map((f) => f.sha256)).toEqual([null, null, null, null]);
});
it('un tamaño inválido se normaliza a 0, no a NaN', async () => {
stubFetch(() =>
jsonResponse([
{ name: 'a.zip', sha256: 'a'.repeat(64) },
{ name: 'b.zip', size: -5, sha256: 'b'.repeat(64) },
{ name: 'c.zip', size: 'mucho', sha256: 'c'.repeat(64) }
])
);
expect((await listPackageFiles('1.1.0')).map((f) => f.size)).toEqual([0, 0, 0]);
});
it('404 indica que la versión no existe', async () => {
stubFetch(() => new Response('', { status: 404 }));
await expect(listPackageFiles('9.9.9')).rejects.toMatchObject({ status: 404 });
});
});
describe('openPackageFile', () => {
it('devuelve el stream y el tamaño declarado', async () => {
stubFetch((url) => {
expect(url).toContain('/api/packages/ADUANASOFT/generic/cloudrestoreas/1.1.0/');
expect(url).toContain('CloudRestoreAS-1.1.0-win-x86_64.zip');
return new Response('bytes', { status: 200, headers: { 'content-length': '5' } });
});
const download = await openPackageFile('1.1.0', 'CloudRestoreAS-1.1.0-win-x86_64.zip');
expect(download.size).toBe(5);
expect(download.body).toBeTruthy();
});
it('404 se traduce a un mensaje que nombra el archivo y la versión', async () => {
stubFetch(() => new Response('', { status: 404 }));
await expect(openPackageFile('1.1.0', 'falta.zip')).rejects.toThrow(/falta\.zip.*1\.1\.0/);
});
it('sin content-length el tamaño queda en null (no en 0)', async () => {
stubFetch(() => new Response('bytes', { status: 200 }));
const download = await openPackageFile('1.1.0', 'x.zip');
expect(download.size).toBeNull();
});
});

View File

@@ -0,0 +1,229 @@
/**
* Cliente del registro de paquetes GENÉRICOS de Gitea, donde se publican los binarios de
* CloudRestoreAS (`publish-release.sh` los sube).
*
* Gitea es la fuente de verdad de los bytes y **calcula el sha256 de cada archivo**, así
* que el panel no necesita mantener su propio manifiesto de integridad: descubre versiones
* leyendo esta API y verifica cada descarga contra ese hash.
*
* El panel solo LEE: el token requiere scope `read:package`. Publicar es tarea del script
* de build, que corre en la máquina de desarrollo con un token distinto.
*/
import { env } from '$env/dynamic/private';
import { logger } from './logger';
const DEFAULT_BASE_URL = 'https://git.aduanasoft.com';
const DEFAULT_OWNER = 'ADUANASOFT';
const DEFAULT_PACKAGE = 'cloudrestoreas';
/** Timeouts: listar metadatos es rápido; descargar un artefacto de ~270 MB no. */
const METADATA_TIMEOUT_MS = 15_000;
export class GiteaError extends Error {
constructor(
public status: number,
message: string
) {
super(message);
this.name = 'GiteaError';
}
}
export interface GiteaPackageFile {
name: string;
size: number;
sha256: string | null;
}
export interface GiteaPackageVersion {
version: string;
created_at: string | null;
}
function baseUrl(): string {
return (env.GITEA_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, '');
}
function owner(): string {
return env.GITEA_OWNER || DEFAULT_OWNER;
}
export function packageName(): string {
return env.CRAS_PACKAGE_NAME || DEFAULT_PACKAGE;
}
/**
* Sin token no se puede leer el registro. Se expone para que la UI muestre "Gitea no
* configurado" en lugar de reventar con un 500 en cada carga de la página.
*/
export function isGiteaConfigured(): boolean {
return Boolean(env.GITEA_TOKEN && env.GITEA_TOKEN.trim());
}
function authHeaders(): Record<string, string> {
const token = (env.GITEA_TOKEN || '').trim();
if (!token) {
throw new GiteaError(500, 'GITEA_TOKEN no configurado en el panel');
}
return { Authorization: `token ${token}`, Accept: 'application/json' };
}
/** Descripción del paquete para mostrar en la UI y en los logs. */
export function packageLocation(): string {
return `${baseUrl()}/api/packages/${owner()}/generic/${packageName()}`;
}
async function getJson<T>(url: string): Promise<T> {
let response: Response;
try {
response = await fetch(url, {
headers: authHeaders(),
signal: AbortSignal.timeout(METADATA_TIMEOUT_MS)
});
} catch (err) {
if (err instanceof GiteaError) throw err;
const message = err instanceof Error ? err.message : String(err);
throw new GiteaError(503, `No se pudo contactar a Gitea: ${message}`);
}
if (response.status === 401 || response.status === 403) {
throw new GiteaError(
response.status,
'Gitea rechazó el token del panel (revisa GITEA_TOKEN y su scope read:package)'
);
}
if (response.status === 404) {
throw new GiteaError(404, 'El paquete o la versión no existe en Gitea');
}
if (!response.ok) {
throw new GiteaError(502, `Gitea respondió ${response.status}`);
}
try {
return (await response.json()) as T;
} catch {
throw new GiteaError(502, 'Gitea no devolvió JSON válido');
}
}
interface RawPackage {
name?: unknown;
version?: unknown;
created_at?: unknown;
}
/**
* Versiones publicadas del paquete, más nuevas primero según Gitea.
*
* El filtro por nombre se hace aquí y no solo con `q=`: ese parámetro busca por
* coincidencia parcial, así que un paquete llamado `cloudrestoreas-beta` entraría en los
* resultados y sus artefactos se registrarían como si fueran de producción.
*/
export async function listPackageVersions(): Promise<GiteaPackageVersion[]> {
const pkg = packageName();
const url =
`${baseUrl()}/api/v1/packages/${encodeURIComponent(owner())}` +
`?type=generic&q=${encodeURIComponent(pkg)}&limit=100`;
const raw = await getJson<RawPackage[]>(url);
if (!Array.isArray(raw)) {
throw new GiteaError(502, 'Gitea devolvió un formato inesperado al listar paquetes');
}
const versions: GiteaPackageVersion[] = [];
for (const entry of raw) {
if (String(entry?.name ?? '') !== pkg) continue;
const version = String(entry?.version ?? '').trim();
if (!version) continue;
versions.push({
version,
created_at: entry?.created_at ? String(entry.created_at) : null
});
}
return versions;
}
interface RawPackageFile {
name?: unknown;
size?: unknown;
sha256?: unknown;
}
/**
* Archivos de una versión, con el tamaño y el sha256 que calculó Gitea. Ese hash es el que
* se guarda en cras_releases y contra el que se verifica la descarga a la caché local.
*/
export async function listPackageFiles(version: string): Promise<GiteaPackageFile[]> {
const url =
`${baseUrl()}/api/v1/packages/${encodeURIComponent(owner())}/generic/` +
`${encodeURIComponent(packageName())}/${encodeURIComponent(version)}/files`;
const raw = await getJson<RawPackageFile[]>(url);
if (!Array.isArray(raw)) {
throw new GiteaError(502, 'Gitea devolvió un formato inesperado al listar archivos');
}
const files: GiteaPackageFile[] = [];
for (const entry of raw) {
const name = String(entry?.name ?? '').trim();
if (!name) continue;
const size = Number(entry?.size);
const sha256 = String(entry?.sha256 ?? '')
.trim()
.toLowerCase();
files.push({
name,
size: Number.isFinite(size) && size >= 0 ? size : 0,
sha256: /^[0-9a-f]{64}$/.test(sha256) ? sha256 : null
});
}
return files;
}
export interface GiteaDownload {
body: ReadableStream<Uint8Array>;
size: number | null;
}
/**
* Abre el stream de descarga de un archivo del paquete.
*
* SIN timeout: son artefactos de cientos de MB y un AbortSignal.timeout cortaría la
* transferencia a media descarga en un enlace lento. El llamador (cras-artifacts) verifica
* el sha256 al terminar, así que una descarga truncada se detecta ahí y no pasa por buena.
*/
export async function openPackageFile(version: string, fileName: string): Promise<GiteaDownload> {
const url =
`${baseUrl()}/api/packages/${encodeURIComponent(owner())}/generic/` +
`${encodeURIComponent(packageName())}/${encodeURIComponent(version)}/` +
encodeURIComponent(fileName);
let response: Response;
try {
response = await fetch(url, { headers: { Authorization: authHeaders().Authorization } });
} catch (err) {
if (err instanceof GiteaError) throw err;
const message = err instanceof Error ? err.message : String(err);
throw new GiteaError(503, `No se pudo descargar de Gitea: ${message}`);
}
if (!response.ok || !response.body) {
logger.error({
message: 'Fallo descargando artefacto de Gitea',
context: { version, file_name: fileName, status: response.status }
});
if (response.status === 404) {
throw new GiteaError(404, `El archivo ${fileName} no existe en la versión ${version}`);
}
if (response.status === 401 || response.status === 403) {
throw new GiteaError(response.status, 'Gitea rechazó el token del panel');
}
throw new GiteaError(502, `Gitea respondió ${response.status} al descargar ${fileName}`);
}
const declared = Number(response.headers.get('content-length'));
return {
body: response.body,
size: Number.isFinite(declared) && declared > 0 ? declared : null
};
}