diff --git a/.env.example b/.env.example index c4933c4..ba4e110 100644 --- a/.env.example +++ b/.env.example @@ -44,3 +44,33 @@ CLOUDRESTORE_API_TOKEN= # Generar con: # node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" ENCRYPTION_KEY= + +# ============================================================================ +# Distribución de versiones de CloudRestoreAS (pantalla /versiones-cras) +# +# Los binarios se publican en el registro de paquetes GENÉRICOS de Gitea desde el build +# local del repo CloudRecoveryAS (`build-all.sh --publish`). El panel los descubre leyendo +# esa API, los descarga verificando el sha256 que Gitea calcula, y los instala en cada +# servidor de restauración por SSH/SFTP. +# ============================================================================ + +# Instancia de Gitea y organización dueña del paquete. +GITEA_BASE_URL=https://git.aduanasoft.com +GITEA_OWNER=ADUANASOFT +CRAS_PACKAGE_NAME=cloudrestoreas + +# PAT de Gitea. El panel solo LEE el registro, así que basta el scope `read:package`. +# (El token con `write:package` vive en la máquina de build, no aquí.) +# Sin este token /versiones-cras carga pero avisa que no puede sincronizar. +GITEA_TOKEN= + +# Caché local de artefactos. Cada uno pesa ~270 MB y se publican dos por versión, así que la +# carpeta crece ~540 MB por versión; CRAS_CACHE_KEEP_VERSIONS limita cuántas se conservan +# (las versiones activas nunca se borran). +CRAS_RELEASES_DIR=./local-cras-releases +CRAS_CACHE_KEEP_VERSIONS=3 + +# URL con la que el agente instalado reportará al panel. El instalador remoto la siembra en el +# config/.env del servidor destino, así que TIENE que ser alcanzable desde esos servidores (no +# localhost). Si se omite se usa ORIGIN. +PANEL_PUBLIC_URL= diff --git a/.gitignore b/.gitignore index 8d4b112..8135789 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ certs/ *.crt *.p12 *.pfx +local-cras-releases/ diff --git a/README.md b/README.md index fe3c0c6..23dcf58 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,51 @@ docker push dev.aduanasoft.com:8443/databases_a24c/frontend:latest - **Servidores de Restauración** (`/servidores-restauracion`): admin de Alfa/Omega/Gamma y carpeta de entrada reportada por CloudRestoreAS (solo lectura). - Contrato y prueba local: ver `INTEGRACION_PANEL.md` en el repo CloudRecoveryAS (mismo token en `CLOUDRESTORE_API_TOKEN` y en la config PANEL de CloudRestoreAS). +### Versiones CRAS (`/versiones-cras`) — instalación y actualización remota + +Distribución de los binarios del agente. Los artefactos se publican en el registro de +paquetes **genéricos de Gitea** desde el build local del repo CloudRecoveryAS +(`build-all.sh --publish`); el panel los descubre leyendo esa API y los despliega por SSH. + +``` +build local (WSL) → Gitea: ADUANASOFT/generic/cloudrestoreas/ + ↓ (el panel lee la API y guarda metadatos + sha256) + a24c.cras_releases → caché en CRAS_RELEASES_DIR + ↓ (SFTP + exec) + servidor de restauración +``` + +Flujo de operación: + +1. **Sincronizar con Gitea** — registra las versiones publicadas en `a24c.cras_releases`. + Nunca activa nada por su cuenta: publicar no debe equivaler a desplegar. +2. **Activar** — marca la versión que se instalará, **una por plataforma+arquitectura** + (índice único parcial en la BD, para que Windows y Linux tengan cada una la suya). +3. **Precargar** *(opcional)* — descarga y verifica el artefacto antes de instalar, para + separar el tiempo de descarga del de instalación. +4. **Instalar / Actualizar** por servidor — sube el paquete por SFTP, **verifica el sha256 en + el destino**, corre el instalador que viene dentro del paquete y siembra `config/.env` con + la URL, el token y la instancia: el servidor queda operativo sin configuración manual. + +Detalles que importan: + +- Los binarios **no** se guardan en Postgres (pesan ~270 MB); la BD solo tiene metadatos. +- El servidor destino **no descarga nada de internet**: el binario es autocontenido. +- El token del panel viaja al destino en un archivo `0600` por SFTP, **nunca como argumento + de un comando** (sería visible en `ps` y en el historial del servidor). +- Se exige **root o `sudo -n`** en Linux (y cuenta administradora en Windows), validado + *antes* de transferir el artefacto. No se le pasa la contraseña a `sudo`. +- El progreso se persiste paso a paso en `a24c.cras_install_runs.steps`; la UI lo consulta + por polling en `/versiones-cras/install-runs`. +- No hay subida de archivos por el navegador a propósito: la fuente de verdad es Gitea. + +Variables nuevas en `.env`: `GITEA_BASE_URL`, `GITEA_OWNER`, `GITEA_TOKEN` +(scope `read:package`), `CRAS_PACKAGE_NAME`, `CRAS_RELEASES_DIR`, +`CRAS_CACHE_KEEP_VERSIONS`, `PANEL_PUBLIC_URL`. Ver `.env.example`. + +Esquema: migración `e2f3a4b5c6d7` en el repo a24c (autoritativa), replicada en +`database/schema.sql` y en el fallback `ensureCrasSchema()` de `cras-releases.ts`. + ## Notas de Migración - **DataTables**: Se inicializan en el cliente dentro de `onMount` para mantener la compatibilidad con las tablas interactivas originales. diff --git a/database/schema.sql b/database/schema.sql index 0a37f84..5f68e79 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -140,6 +140,14 @@ CREATE TABLE IF NOT EXISTS a24c.restore_job_logs ( restored_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +-- Descartar sin borrar: las vistas de restaurados/fallidos filtran por dismissed_at IS NULL, +-- pero listRestoreJobLogSummaries sigue contando TODO. Un DELETE real bajaría los contadores de +-- /servidores-restauracion y podría retroceder la última restauración exitosa de un servidor. +ALTER TABLE a24c.restore_job_logs ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ; +ALTER TABLE a24c.restore_job_logs ADD COLUMN IF NOT EXISTS dismissed_by VARCHAR(128); +CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_pendientes + ON a24c.restore_job_logs (status, restored_at DESC) WHERE dismissed_at IS NULL; + CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_target ON a24c.restore_job_logs (restore_target_id); CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_restored_at @@ -164,5 +172,76 @@ CREATE TABLE IF NOT EXISTS a24c.cloudrestore_status ( reported_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +-- Identidad del build instalado, reportada por el agente en instance-config. Es la fuente +-- autoritativa para decidir qué artefacto de CRAS le toca a cada servidor; un agente anterior +-- a 1.1.0 no las manda y quedan NULL (el panel cae a restore_targets.os). +ALTER TABLE a24c.cloudrestore_status ADD COLUMN IF NOT EXISTS processed_folder VARCHAR(500); +ALTER TABLE a24c.cloudrestore_status ADD COLUMN IF NOT EXISTS platform VARCHAR(20); +ALTER TABLE a24c.cloudrestore_status ADD COLUMN IF NOT EXISTS arch VARCHAR(20); +-- Carpeta del ejecutable (APP_DIR). Distinta de input_folder: ahí vive config/.env. +ALTER TABLE a24c.cloudrestore_status ADD COLUMN IF NOT EXISTS install_path VARCHAR(500); + COMMENT ON TABLE a24c.cloudrestore_status IS 'Carpeta de entrada vigente reportada por CloudRestoreAS. Solo lectura en el panel.'; + +-- ============================================================================ +-- Distribución de versiones de CloudRestoreAS (migración a24c e2f3a4b5c6d7). +-- +-- Los binarios NO viven aquí: se publican en el registro de paquetes genéricos de Gitea +-- (ADUANASOFT/generic/cloudrestoreas/) y el panel los cachea en disco. Un artefacto +-- pesa ~270 MB, así que guardarlo como BLOB en la base no es viable. +-- ============================================================================ + +-- Catálogo de versiones publicadas (metadatos + el sha256 que calcula Gitea). +CREATE TABLE IF NOT EXISTS a24c.cras_releases ( + 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) +); + +-- Una sola versión activa POR plataforma+arquitectura, garantizado en la base: así Windows y +-- Linux pueden tener cada una la suya, a diferencia de un is_active singleton global. +CREATE UNIQUE INDEX IF NOT EXISTS idx_a24c_cras_releases_active + ON a24c.cras_releases (platform, arch) WHERE is_active; +CREATE INDEX IF NOT EXISTS idx_a24c_cras_releases_version + ON a24c.cras_releases (version); + +-- Bitácora de instalaciones remotas, con progreso paso a paso para que la UI lo siga. +CREATE TABLE IF NOT EXISTS a24c.cras_install_runs ( + id SERIAL PRIMARY KEY, + restore_target_id INTEGER REFERENCES a24c.restore_targets (id) ON DELETE SET NULL, + release_id INTEGER REFERENCES a24c.cras_releases (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')) +); + +CREATE INDEX IF NOT EXISTS idx_a24c_cras_install_runs_target + ON a24c.cras_install_runs (restore_target_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_a24c_cras_install_runs_running + ON a24c.cras_install_runs (restore_target_id) WHERE status = 'running'; + +COMMENT ON TABLE a24c.cras_releases IS + 'Catálogo de versiones de CloudRestoreAS publicadas en Gitea. Solo metadatos; los binarios viven en Gitea.'; +COMMENT ON TABLE a24c.cras_install_runs IS + 'Bitácora de instalaciones remotas de CloudRestoreAS, con progreso paso a paso en steps.'; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 52e79e6..3436600 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -21,9 +21,31 @@ services: # JWT - JWT_SECRET=${JWT_SECRET} + # Cifrado en reposo de las credenciales SQL/SSH de restore_targets (AES-256-GCM). + # Sin esta clave el panel no puede descifrar la contraseña SSH y por lo tanto no puede + # instalar CRAS en un servidor. + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY es obligatoria} + + # Token de servicio que usan los agentes CloudRestoreAS en /api/restore/*. Los endpoints + # fallan cerrado (500) sin él, y el instalador remoto lo siembra en el .env del agente. + - CLOUDRESTORE_API_TOKEN=${CLOUDRESTORE_API_TOKEN:?CLOUDRESTORE_API_TOKEN es obligatorio} + # Ruta de respaldos montada desde el host - BACKUP_PATH=/data/backups + # --- Distribución de versiones de CloudRestoreAS ----------------------- + # Gitea es la fuente de verdad de los binarios; el panel solo LEE (scope read:package). + - GITEA_BASE_URL=${GITEA_BASE_URL:-https://git.aduanasoft.com} + - GITEA_OWNER=${GITEA_OWNER:-ADUANASOFT} + - GITEA_TOKEN=${GITEA_TOKEN:-} + - CRAS_PACKAGE_NAME=${CRAS_PACKAGE_NAME:-cloudrestoreas} + # Caché local de artefactos (~270 MB cada uno, 2 por versión). + - CRAS_RELEASES_DIR=/data/cras-releases + - CRAS_CACHE_KEEP_VERSIONS=${CRAS_CACHE_KEEP_VERSIONS:-3} + # URL con la que el agente instalado reportará al panel. Debe ser alcanzable desde los + # servidores de restauración, no localhost. + - PANEL_PUBLIC_URL=${PANEL_PUBLIC_URL:-${ORIGIN}} + - PORT=3000 - ORIGIN=${ORIGIN} - NODE_ENV=production @@ -37,6 +59,10 @@ services: - SMTP_USE_TLS=${SMTP_USE_TLS:-true} volumes: - ${BACKUP_HOST_PATH}:/data/backups + # Caché de artefactos de CRAS. Va en un bind-mount del host (no en un volumen anónimo) + # para que sobreviva a los redeploy: re-descargar cientos de MB en cada actualización + # del panel sería desperdicio. + - ${CRAS_RELEASES_HOST_PATH:-./data/cras-releases}:/data/cras-releases extra_hosts: - "host.docker.internal:host-gateway" networks: diff --git a/docker-compose.yml b/docker-compose.yml index fe1af63..c0de8b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,10 +39,23 @@ services: # Integración CloudRestoreAS - CLOUDRESTORE_API_TOKEN=${CLOUDRESTORE_API_TOKEN} - ENCRYPTION_KEY=${ENCRYPTION_KEY} + + # --- Distribución de versiones de CloudRestoreAS ----------------------- + # Gitea es la fuente de verdad de los binarios; el panel solo LEE (scope read:package). + - GITEA_BASE_URL=${GITEA_BASE_URL:-https://git.aduanasoft.com} + - GITEA_OWNER=${GITEA_OWNER:-ADUANASOFT} + - GITEA_TOKEN=${GITEA_TOKEN:-} + - CRAS_PACKAGE_NAME=${CRAS_PACKAGE_NAME:-cloudrestoreas} + - CRAS_RELEASES_DIR=/data/cras-releases + - CRAS_CACHE_KEEP_VERSIONS=${CRAS_CACHE_KEEP_VERSIONS:-3} + # URL con la que el agente instalado reportará al panel; en dev apunta al host. + - PANEL_PUBLIC_URL=${PANEL_PUBLIC_URL:-https://localhost:3000} volumes: # Map the actual backup folder from host to the container's backup path # Local dev: carpeta escribible del repo con respaldos sintéticos (read-only en el contenedor) - ./local-backups:/data/backups:ro + # Caché de artefactos de CRAS (~270 MB cada uno). Escribible: el panel descarga aquí. + - ./local-cras-releases:/data/cras-releases extra_hosts: - "host.docker.internal:host-gateway" depends_on: diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 38f4299..377d3b3 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -126,7 +126,8 @@ ]; const navAdmin: NavItem[] = [ { href: '/usuarios', label: 'Gestión de Usuarios', icon: 'manage_accounts' }, - { href: '/servidores-restauracion', label: 'Servidores de Restauración', icon: 'storage' } + { href: '/servidores-restauracion', label: 'Servidores de Restauración', icon: 'storage' }, + { href: '/versiones-cras', label: 'Versiones CRAS', icon: 'system_update' } ]; function isActive(href: string): boolean { diff --git a/src/lib/cras-version.test.ts b/src/lib/cras-version.test.ts new file mode 100644 index 0000000..bd73a52 --- /dev/null +++ b/src/lib/cras-version.test.ts @@ -0,0 +1,175 @@ +/** + * Comparación de versiones y normalización de plataforma para CloudRestoreAS. + * + * Es la lógica que decide si a un servidor le falta actualizarse, así que los casos que se + * prueban son los que romperían eso en silencio: comparación lexicográfica (1.10 vs 1.9), + * versiones de distinta longitud, y valores no comparables. + */ +import { describe, expect, it } from 'vitest'; +import { + compareVersions, + effectiveArch, + effectivePlatform, + isCrasPlatform, + isNewer, + isValidVersion, + osToPlatform, + parseVersion, + platformLabel, + DEFAULT_ARCH +} from './cras-version'; + +describe('isValidVersion', () => { + it('acepta versiones de puntos y números', () => { + for (const v of ['1', '1.0', '1.1.0', '26.7.1.4']) { + expect(isValidVersion(v), v).toBe(true); + } + }); + + it('rechaza lo que el PANEL no podría ordenar', () => { + for (const v of ['1.0.0-rc1', 'v1.0.0', '1.0.0a', '', ' ', 'latest', '1.2.3.4.5']) { + expect(isValidVersion(v), String(v)).toBe(false); + } + }); + + it('rechaza valores que no son cadena', () => { + expect(isValidVersion(null)).toBe(false); + expect(isValidVersion(undefined)).toBe(false); + expect(isValidVersion(110)).toBe(false); + }); +}); + +describe('parseVersion', () => { + it('descompone en enteros', () => { + expect(parseVersion('1.10.2')).toEqual([1, 10, 2]); + expect(parseVersion(' 2.0 ')).toEqual([2, 0]); + }); + + it('devuelve null si no es comparable', () => { + expect(parseVersion('1.0.0-rc1')).toBeNull(); + }); +}); + +describe('compareVersions', () => { + it('compara numéricamente, no como texto', () => { + // El error clásico: como cadenas, "1.9.0" > "1.10.0". + expect(compareVersions('1.10.0', '1.9.0')).toBe(1); + expect(compareVersions('1.9.0', '1.10.0')).toBe(-1); + }); + + it('rellena con ceros las de distinta longitud', () => { + expect(compareVersions('1.2', '1.2.0')).toBe(0); + expect(compareVersions('1.2.1', '1.2')).toBe(1); + expect(compareVersions('2', '1.9.9.9')).toBe(1); + }); + + it('es simétrica', () => { + expect(compareVersions('1.1.0', '1.1.0')).toBe(0); + expect(compareVersions('26.7.1.4', '26.7.1.3')).toBe(1); + expect(compareVersions('26.7.1.3', '26.7.1.4')).toBe(-1); + }); + + it('devuelve null cuando alguna no es comparable', () => { + expect(compareVersions('1.0.0-rc1', '1.0.0')).toBeNull(); + expect(compareVersions('1.0.0', 'latest')).toBeNull(); + }); +}); + +describe('isNewer', () => { + it('detecta una versión más nueva', () => { + expect(isNewer('1.2.0', '1.1.0')).toBe(true); + expect(isNewer('1.10.0', '1.9.0')).toBe(true); + }); + + it('no ofrece downgrade ni reinstalación de la misma', () => { + expect(isNewer('1.1.0', '1.2.0')).toBe(false); + expect(isNewer('1.1.0', '1.1.0')).toBe(false); + }); + + it('sin versión instalada, hay algo que instalar', () => { + expect(isNewer('1.1.0', null)).toBe(true); + expect(isNewer('1.1.0', undefined)).toBe(true); + expect(isNewer('1.1.0', ' ')).toBe(true); + }); + + it('ante una versión no comparable prefiere NO ofrecer nada', () => { + // Preferible perderse una actualización que empujar un downgrade por un dato sucio. + expect(isNewer('1.0.0-rc1', '1.0.0')).toBe(false); + expect(isNewer('1.2.0', 'desconocida')).toBe(false); + }); +}); + +describe('osToPlatform', () => { + it('reconoce Windows en texto libre', () => { + for (const os of ['Windows Server 2019', 'windows 11', 'WIN SERVER 2022', 'Win']) { + expect(osToPlatform(os), os).toBe('windows'); + } + }); + + it('reconoce distribuciones de Linux', () => { + for (const os of [ + 'Ubuntu 22.04', + 'Debian 12', + 'CentOS 7', + 'RHEL 9', + 'Red Hat Enterprise', + 'Rocky Linux 9', + 'AlmaLinux', + 'SUSE', + 'Oracle Linux 8' + ]) { + expect(osToPlatform(os), os).toBe('linux'); + } + }); + + it('devuelve null si no puede decidir, en lugar de adivinar', () => { + // Adivinar mandaría el binario equivocado; la UI pide elegir a mano. + for (const os of ['', ' ', null, undefined, 'servidor de la sucursal', 'macOS 14']) { + expect(osToPlatform(os as string), String(os)).toBeNull(); + } + }); +}); + +describe('effectivePlatform', () => { + it('lo reportado por el agente manda sobre el texto libre', () => { + expect(effectivePlatform('linux', 'Windows Server 2019')).toBe('linux'); + expect(effectivePlatform('windows', 'Ubuntu 22.04')).toBe('windows'); + }); + + it('cae al texto libre cuando el agente no reportó', () => { + expect(effectivePlatform(null, 'Ubuntu 22.04')).toBe('linux'); + expect(effectivePlatform(' ', 'Windows Server 2022')).toBe('windows'); + }); + + it('ignora una plataforma reportada inválida', () => { + expect(effectivePlatform('solaris', 'Ubuntu 22.04')).toBe('linux'); + expect(effectivePlatform('solaris', null)).toBeNull(); + }); +}); + +describe('effectiveArch', () => { + it('usa la reportada y normaliza a minúsculas', () => { + expect(effectiveArch('X86_64')).toBe('x86_64'); + expect(effectiveArch('arm64')).toBe('arm64'); + }); + + it('asume la única arquitectura que se publica hoy si falta', () => { + expect(effectiveArch(null)).toBe(DEFAULT_ARCH); + expect(effectiveArch(' ')).toBe(DEFAULT_ARCH); + }); +}); + +describe('isCrasPlatform y platformLabel', () => { + it('valida el vocabulario del CHECK de cras_releases', () => { + expect(isCrasPlatform('windows')).toBe(true); + expect(isCrasPlatform('linux')).toBe(true); + expect(isCrasPlatform('solaris')).toBe(false); + expect(isCrasPlatform(null)).toBe(false); + }); + + it('etiqueta legible, incluso sin dato', () => { + expect(platformLabel('windows')).toBe('Windows'); + expect(platformLabel('linux')).toBe('Linux'); + expect(platformLabel(null)).toBe('Sin determinar'); + }); +}); diff --git a/src/lib/cras-version.ts b/src/lib/cras-version.ts new file mode 100644 index 0000000..172247b --- /dev/null +++ b/src/lib/cras-version.ts @@ -0,0 +1,218 @@ +/** + * Comparación de versiones de CloudRestoreAS y normalización de plataforma. + * + * Módulo PURO (sin acceso a red ni a BD) para que sea testeable y usable tanto en el + * servidor como en el navegador. Es la pieza que decide si un servidor tiene una versión + * vieja, así que un error aquí se traduce en actualizaciones que nunca se ofrecen o que se + * ofrecen en reversa. + */ + +/** Plataformas para las que se publican artefactos (espejo del CHECK de cras_releases). */ +export const CRAS_PLATFORMS = ['windows', 'linux'] as const; +export type CrasPlatform = (typeof CRAS_PLATFORMS)[number]; + +export const DEFAULT_ARCH = 'x86_64'; + +export function isCrasPlatform(value: unknown): value is CrasPlatform { + return typeof value === 'string' && (CRAS_PLATFORMS as readonly string[]).includes(value); +} + +/** + * Versión válida: solo puntos y números (1.1.0, 26.7.1.4). CloudRestoreAS lo garantiza en + * package-release.sh, que aborta el empaquetado si el formato no cumple. + */ +const VERSION_RE = /^\d+(\.\d+){0,3}$/; + +export function isValidVersion(value: unknown): boolean { + return typeof value === 'string' && VERSION_RE.test(value.trim()); +} + +/** Convierte "1.10.2" en [1, 10, 2]. Devuelve null si no es una versión comparable. */ +export function parseVersion(value: string): number[] | null { + const raw = String(value ?? '').trim(); + if (!VERSION_RE.test(raw)) return null; + return raw.split('.').map((part) => Number.parseInt(part, 10)); +} + +/** + * Orden numérico por componente, rellenando con ceros: "1.2" == "1.2.0" y "1.10" > "1.9" + * (una comparación de cadenas daría lo contrario, que es el error clásico). + * + * Devuelve -1 si a < b, 0 si son equivalentes, 1 si a > b, y null si alguna no es + * comparable — el llamador debe tratar null como "no sé", nunca como "iguales". + */ +export function compareVersions(a: string, b: string): -1 | 0 | 1 | null { + const left = parseVersion(a); + const right = parseVersion(b); + if (!left || !right) return null; + + const length = Math.max(left.length, right.length); + for (let i = 0; i < length; i++) { + const x = left[i] ?? 0; + const y = right[i] ?? 0; + if (x < y) return -1; + if (x > y) return 1; + } + return 0; +} + +/** + * ¿`candidate` es más nueva que `installed`? + * + * Sin versión instalada (servidor sin agente todavía) cuenta como que sí hay algo que + * instalar. Si alguna versión no es comparable devuelve false: es preferible no ofrecer + * una actualización que ofrecer un downgrade por una comparación inválida. + */ +export function isNewer(candidate: string, installed: string | null | undefined): boolean { + if (!isValidVersion(candidate)) return false; + const current = (installed ?? '').trim(); + if (!current) return true; + return compareVersions(candidate, current) === 1; +} + +/** + * Normaliza el texto libre de `restore_targets.os` a una plataforma. + * + * Ese campo lo captura a mano el operador ("Windows Server 2019", "Ubuntu 22.04"), así que + * solo sirve como respaldo para la PRIMERA instalación, cuando el agente todavía no ha + * reportado su plataforma real. Devuelve null si no se puede decidir — la UI entonces pide + * elegir a mano en lugar de adivinar y mandar el binario equivocado. + */ +export function osToPlatform(os: string | null | undefined): CrasPlatform | null { + const text = (os ?? '').trim().toLowerCase(); + if (!text) return null; + if (text.includes('windows') || text.includes('win server') || /\bwin\b/.test(text)) { + return 'windows'; + } + const linuxHints = [ + 'linux', + 'ubuntu', + 'debian', + 'centos', + 'rhel', + 'red hat', + 'redhat', + 'fedora', + 'suse', + 'rocky', + 'alma', + 'oracle linux' + ]; + if (linuxHints.some((hint) => text.includes(hint))) return 'linux'; + return null; +} + +/** + * Plataforma efectiva de un destino: lo que reportó el agente manda sobre el texto libre. + * `reported` viene de cloudrestore_status.platform y es autoritativo. + */ +export function effectivePlatform( + reported: string | null | undefined, + os: string | null | undefined +): CrasPlatform | null { + const value = (reported ?? '').trim().toLowerCase(); + if (isCrasPlatform(value)) return value; + return osToPlatform(os); +} + +/** Arquitectura efectiva; si el agente no la reportó se asume la única que se publica hoy. */ +export function effectiveArch(reported: string | null | undefined): string { + const value = (reported ?? '').trim().toLowerCase(); + return value || DEFAULT_ARCH; +} + +// ============================================================================ +// Ruta de instalación +// +// Es un espacio de rutas SEPARADO de `input_folder` (la carpeta que vigila el agente, que +// puede ser un share UNC en otra máquina) y de `remote_inbox_path`. La reporta el agente +// (`APP_DIR` = carpeta del ejecutable), y ahí vive `config/.env` con las rutas de trabajo +// que el operador haya personalizado. Instalar en la ruta equivocada crea una segunda +// instalación con la configuración default y deja huérfano el `.env` personalizado. +// ============================================================================ + +export const DEFAULT_INSTALL_PATHS: Record = { + linux: '/opt/cloudrestoreas', + windows: 'C:\\Aduanasoft\\CloudRestoreAS' +}; + +/** + * Valida una ruta de instalación. Se aplica en serio porque el valor llega **por red** desde + * el agente y termina interpolado en un comando de shell y en un script de PowerShell. + * + * Se rechazan las rutas UNC a propósito: el bootstrap escribe `config/` junto al ejecutable, y + * una tarea programada como SYSTEM no ve shares de red, así que un agente instalado en un + * share no podría arrancar. + */ +export function isSafeInstallPath(path: unknown, platform: CrasPlatform): boolean { + const value = String(path ?? '').trim(); + if (!value || value.length > 400) return false; + // Comillas, metacaracteres de shell y saltos de línea: fuera, sin excepciones. + if (/["'`$;&|<>\n\r*?()[\]{}!]/.test(value)) return false; + if (value.includes('..')) return false; + + if (platform === 'windows') { + if (value.startsWith('\\\\')) return false; // UNC + return /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]*$/.test(value); + } + if (value.startsWith('//')) return false; // equivalente POSIX de un share + return /^\/[A-Za-z0-9 ._/-]*$/.test(value); +} + +/** + * Ruta efectiva: la que reportó el agente si es válida, o el default de la plataforma. + * Devuelve null si no se conoce la plataforma — sin ella no hay default que aplicar. + */ +export function effectiveInstallPath( + reported: string | null | undefined, + platform: CrasPlatform | null | undefined +): string | null { + if (!platform || !isCrasPlatform(platform)) return null; + const value = String(reported ?? '').trim(); + if (value && isSafeInstallPath(value, platform)) { + return value.replace(/[/\\]+$/, '') || value; + } + return DEFAULT_INSTALL_PATHS[platform]; +} + +/** True si la ruta reportada por el agente es la default de su plataforma. */ +export function isDefaultInstallPath( + path: string | null | undefined, + platform: CrasPlatform | null | undefined +): boolean { + if (!platform || !isCrasPlatform(platform)) return false; + return String(path ?? '').trim() === DEFAULT_INSTALL_PATHS[platform]; +} + +/** + * ¿La ruta de instalación cae dentro de una carpeta de trabajo del agente? + * + * Instalar el binario dentro de `Entrada`/`Procesados` sería grave: el agente vigila esa + * carpeta y trataría de procesar sus propios archivos como si fueran respaldos. + */ +export function isInsideWorkFolder( + installPath: string | null | undefined, + workFolders: (string | null | undefined)[] +): boolean { + const norm = (p: string | null | undefined) => + String(p ?? '') + .trim() + .replace(/\\/g, '/') + .replace(/\/+$/, '') + .toLowerCase(); + + const target = norm(installPath); + if (!target) return false; + return workFolders.some((folder) => { + const base = norm(folder); + if (!base) return false; + return target === base || target.startsWith(`${base}/`) || base.startsWith(`${target}/`); + }); +} + +/** Etiqueta legible para la UI. */ +export function platformLabel(platform: string | null | undefined): string { + if (platform === 'windows') return 'Windows'; + if (platform === 'linux') return 'Linux'; + return 'Sin determinar'; +} diff --git a/src/lib/restore-filter.test.ts b/src/lib/restore-filter.test.ts new file mode 100644 index 0000000..b0c5f20 --- /dev/null +++ b/src/lib/restore-filter.test.ts @@ -0,0 +1,170 @@ +/** + * Filtrado de las listas de restauraciones. + * + * Lo que más importa aquí: que los campos nulos no revienten (la mitad de las columnas de + * `restore_job_logs` son nullable y los JOIN a `restore_targets`/`database_nodes` son LEFT, así + * que `server_name`, `node_key` y `client_name` llegan null con frecuencia) y que buscar dentro + * de `error_message` funcione, que es lo que permite agrupar fallos por causa. + */ +import { describe, expect, it } from 'vitest'; +import { + filterFailed, + filterRestored, + matchesFailed, + matchesRestored, + normalizeTerm +} from './restore-filter'; + +const RESTAURADO = { + id: 1, + server_name: 'Alfa', + node_key: 'GENERICA-01', + client_name: 'Importadora del Norte', + db_name: 'GENERICA_TEST', + filename: 'GENERICA-TEST.ZIP' +}; + +const FALLIDO = { + id: 2, + server_name: 'Omega', + db_name: 'EMPRESA_DB', + filename: 'EMPRESA.ZIP.001', + error_message: 'Timeout al conectar con SQL Server tras 60s' +}; + +describe('normalizeTerm', () => { + it('recorta y baja a minúsculas', () => { + expect(normalizeTerm(' Alfa ')).toBe('alfa'); + }); + + it('nulo, undefined y espacios son "sin filtro"', () => { + expect(normalizeTerm(null)).toBe(''); + expect(normalizeTerm(undefined)).toBe(''); + expect(normalizeTerm(' ')).toBe(''); + }); +}); + +describe('matchesRestored', () => { + it('encuentra por cada uno de sus cinco campos', () => { + for (const term of ['alfa', 'generica-01', 'importadora', 'generica_test', '.zip']) { + expect(matchesRestored(RESTAURADO, term), term).toBe(true); + } + }); + + it('es insensible a mayúsculas en el dato, no solo en el término', () => { + // El dato viene en MAYÚSCULAS de los sistemas legados; el término en minúsculas. + expect(matchesRestored(RESTAURADO, 'generica-test.zip')).toBe(true); + }); + + it('no encuentra lo que no está', () => { + expect(matchesRestored(RESTAURADO, 'omega')).toBe(false); + }); + + it('término vacío siempre coincide', () => { + expect(matchesRestored(RESTAURADO, '')).toBe(true); + }); + + it('no revienta con todos los campos nulos', () => { + const vacio = { + server_name: null, + node_key: null, + client_name: null, + db_name: null, + filename: null + }; + expect(matchesRestored(vacio, 'algo')).toBe(false); + expect(matchesRestored(vacio, '')).toBe(true); + }); + + it('no revienta con campos ausentes', () => { + expect(matchesRestored({}, 'algo')).toBe(false); + }); + + it('no confunde campos al concatenar', () => { + // "alfagenerica" no debe coincidir: la concatenación lleva separador. + expect(matchesRestored(RESTAURADO, 'alfagenerica')).toBe(false); + expect(matchesRestored(RESTAURADO, 'alfa generica-01')).toBe(true); + }); +}); + +describe('matchesFailed', () => { + it('busca dentro del mensaje de error', () => { + // Es la razón de incluir error_message: agrupar por causa. + expect(matchesFailed(FALLIDO, 'timeout')).toBe(true); + expect(matchesFailed(FALLIDO, 'sql server')).toBe(true); + }); + + it('encuentra por servidor, base y archivo', () => { + for (const term of ['omega', 'empresa_db', 'empresa.zip.001']) { + expect(matchesFailed(FALLIDO, term), term).toBe(true); + } + }); + + it('no revienta sin mensaje de error', () => { + const sinError = { ...FALLIDO, error_message: null }; + expect(matchesFailed(sinError, 'timeout')).toBe(false); + expect(matchesFailed(sinError, 'omega')).toBe(true); + }); + + it('no busca en campos que no le corresponden', () => { + // Los fallidos no traen client_name; un término que solo esté ahí no debe coincidir. + expect(matchesFailed({ ...FALLIDO, ...{ client_name: 'Importadora' } }, 'importadora')).toBe( + false + ); + }); +}); + +describe('filterRestored / filterFailed', () => { + const restaurados = [ + RESTAURADO, + { ...RESTAURADO, id: 2, server_name: 'Omega', filename: 'OTRA.ZIP' } + ]; + + it('término vacío devuelve todas las filas', () => { + expect(filterRestored(restaurados, '')).toHaveLength(2); + expect(filterRestored(restaurados, ' ')).toHaveLength(2); + expect(filterRestored(restaurados, null)).toHaveLength(2); + }); + + it('filtra por término', () => { + expect(filterRestored(restaurados, 'omega').map((r) => r.id)).toEqual([2]); + expect(filterRestored(restaurados, 'otra.zip').map((r) => r.id)).toEqual([2]); + }); + + it('devuelve vacío cuando nada coincide', () => { + expect(filterRestored(restaurados, 'nada-de-esto')).toEqual([]); + }); + + it('tolera null y undefined como lista', () => { + expect(filterRestored(null, 'x')).toEqual([]); + expect(filterRestored(undefined, '')).toEqual([]); + expect(filterFailed(null, 'x')).toEqual([]); + }); + + it('no muta el arreglo original', () => { + const original = [...restaurados]; + filterRestored(restaurados, 'omega'); + expect(restaurados).toEqual(original); + }); + + it('devuelve una copia, no la misma referencia, con término vacío', () => { + // Importa porque la UI puede reordenar el resultado sin tocar la data del load. + const result = filterRestored(restaurados, ''); + expect(result).not.toBe(restaurados); + expect(result).toEqual(restaurados); + }); + + it('preserva el orden de entrada (restored_at DESC del SQL)', () => { + const muchos = [1, 2, 3, 4].map((id) => ({ ...RESTAURADO, id })); + expect(filterRestored(muchos, 'alfa').map((r) => r.id)).toEqual([1, 2, 3, 4]); + }); + + it('filterFailed encuentra por mensaje de error en una lista', () => { + const fallidos = [ + FALLIDO, + { ...FALLIDO, id: 3, error_message: 'No se encontró el archivo .bak' } + ]; + expect(filterFailed(fallidos, 'timeout').map((f) => f.id)).toEqual([2]); + expect(filterFailed(fallidos, '.bak').map((f) => f.id)).toEqual([3]); + }); +}); diff --git a/src/lib/restore-filter.ts b/src/lib/restore-filter.ts new file mode 100644 index 0000000..4535f06 --- /dev/null +++ b/src/lib/restore-filter.ts @@ -0,0 +1,91 @@ +/** + * Filtrado de las listas de restauraciones del dashboard. + * + * Vive fuera del `.svelte` para poder probarse: los otros seis filtros del dashboard están + * declarados dentro de `+page.svelte` y ninguno tiene prueba. Este módulo abre el camino para + * migrar los demás sin reescribirlos. + * + * Convención del repo, respetada aquí: `String(x ?? '').toLowerCase()` + `.includes()`. Sin + * debounce ni normalización de acentos, porque ningún buscador del panel los usa y tener dos + * comportamientos distintos sería peor que no tenerlos. Si se agregan, va en todos a la vez. + */ + +/** Campos por los que se busca en Respaldos Restaurados. */ +export interface RestoredSearchable { + server_name?: string | null; + node_key?: string | null; + client_name?: string | null; + db_name?: string | null; + filename?: string | null; +} + +/** Campos por los que se busca en Restores Fallidos. */ +export interface FailedSearchable { + server_name?: string | null; + db_name?: string | null; + filename?: string | null; + error_message?: string | null; +} + +/** Normaliza el término: recortado y en minúsculas. Cadena vacía = sin filtro. */ +export function normalizeTerm(term: string | null | undefined): string { + return String(term ?? '') + .trim() + .toLowerCase(); +} + +/** + * Concatena los campos en una sola cadena en minúsculas para buscar de un tirón. Es el truco + * compacto que ya usa `servidores-restauracion/+page.svelte`, y evita repetir `.toLowerCase()` + * por campo en cada fila. + */ +function haystack(values: (string | null | undefined)[]): string { + return values.map((v) => String(v ?? '')).join(' ').toLowerCase(); +} + +export function matchesRestored(row: RestoredSearchable, term: string): boolean { + if (!term) return true; + return haystack([ + row.server_name, + row.node_key, + row.client_name, + row.db_name, + row.filename + ]).includes(term); +} + +/** + * En los fallidos se busca también dentro de `error_message`: es lo que permite agrupar por + * causa (escribir "timeout" y ver todos los que fallaron por lo mismo). + */ +export function matchesFailed(row: FailedSearchable, term: string): boolean { + if (!term) return true; + return haystack([ + row.server_name, + row.db_name, + row.filename, + row.error_message + ]).includes(term); +} + +/** Filtra Respaldos Restaurados. Con término vacío devuelve el mismo arreglo recibido. */ +export function filterRestored( + rows: readonly T[] | null | undefined, + term: string | null | undefined +): T[] { + const list = rows ?? []; + const needle = normalizeTerm(term); + if (!needle) return [...list]; + return list.filter((row) => matchesRestored(row, needle)); +} + +/** Filtra Restores Fallidos. Con término vacío devuelve el mismo arreglo recibido. */ +export function filterFailed( + rows: readonly T[] | null | undefined, + term: string | null | undefined +): T[] { + const list = rows ?? []; + const needle = normalizeTerm(term); + if (!needle) return [...list]; + return list.filter((row) => matchesFailed(row, needle)); +} diff --git a/src/lib/server/api-error.ts b/src/lib/server/api-error.ts index ff8000c..d182183 100644 --- a/src/lib/server/api-error.ts +++ b/src/lib/server/api-error.ts @@ -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 ) { diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts index 4b578ea..1826210 100644 --- a/src/lib/server/controldesk-pg.ts +++ b/src/lib/server/controldesk-pg.ts @@ -69,13 +69,35 @@ async function ensureCloudRestoreStatusTable(): Promise { 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). */ async function ensureRestoreJobLogColumns(): Promise { for (const stmt of [ `ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS size_bytes BIGINT`, - `ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS rel_path VARCHAR(600)` + `ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS rel_path VARCHAR(600)`, + // Descartar en lugar de borrar: las vistas de restaurados/fallidos filtran por + // dismissed_at IS NULL, pero listRestoreJobLogSummaries sigue contando TODO. Un DELETE + // real bajaría los contadores de /servidores-restauracion y podría retroceder la última + // restauración exitosa de un servidor, moviendo números de otra pantalla. + `ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ`, + `ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS dismissed_by VARCHAR(128)`, + `CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_pendientes + ON ${qRestoreJobLogs()} (status, restored_at DESC) WHERE dismissed_at IS NULL` ]) { try { await pgPool.query(stmt); @@ -612,6 +634,48 @@ export async function getRestoreTargetById(id: number): Promise { + 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 + }; +} + /** * Devuelve el servidor de restauración ASIGNADO a una base de datos, con la contraseña * descifrada. La base se identifica por database_name o node_subnode_key (lo que CloudRestoreAS @@ -928,6 +992,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; } @@ -937,7 +1004,8 @@ export async function listCloudRestoreStatuses(): Promise 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 ` @@ -961,6 +1029,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 { await ensureCloudRestoreStatusTable(); @@ -968,16 +1039,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 + ] ); } @@ -1219,9 +1305,14 @@ export interface FailedRestoreRow { rel_path: string | null; size_bytes: number | null; restored_at: Date; + /** No nulo cuando el operador lo descartó de la lista (el registro se conserva). */ + dismissed_at: Date | null; } -export async function listFailedRestoreJobLogs(limit = 100): Promise { +export async function listFailedRestoreJobLogs( + limit = 100, + includeDismissed = false +): Promise { const capped = Math.min(Math.max(1, Math.trunc(limit)), 500); try { await ensureRestoreJobLogColumns(); @@ -1229,14 +1320,16 @@ export async function listFailedRestoreJobLogs(limit = 100): Promise { +export async function listRestoredRestoreJobLogs( + limit = 200, + includeDismissed = false +): Promise { const capped = Math.min(Math.max(1, Math.trunc(limit)), 500); try { await ensureRestoreJobLogColumns(); @@ -1274,7 +1371,8 @@ export async function listRestoredRestoreJobLogs(limit = 200): Promise { + const clean = sanitizeIds(ids); + if (clean.length === 0) return 0; + await ensureRestoreJobLogColumns(); + const r = await pgPool.query( + `UPDATE ${qRestoreJobLogs()} + SET dismissed_at = now(), dismissed_by = $2 + WHERE id = ANY($1::int[]) AND dismissed_at IS NULL`, + [clean, dismissedBy] + ); + return r.rowCount ?? 0; +} + +/** Revierte un descarte. Devuelve cuántas filas volvieron a la lista. */ +export async function undismissRestoreJobLogs(ids: number[]): Promise { + const clean = sanitizeIds(ids); + if (clean.length === 0) return 0; + await ensureRestoreJobLogColumns(); + const r = await pgPool.query( + `UPDATE ${qRestoreJobLogs()} + SET dismissed_at = NULL, dismissed_by = NULL + WHERE id = ANY($1::int[]) AND dismissed_at IS NOT NULL`, + [clean] + ); + return r.rowCount ?? 0; +} + +/** 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; diff --git a/src/lib/server/cras-artifacts.test.ts b/src/lib/server/cras-artifacts.test.ts new file mode 100644 index 0000000..b249c71 --- /dev/null +++ b/src/lib/server/cras-artifacts.test.ts @@ -0,0 +1,229 @@ +/** + * 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 +} 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([]); + }); +}); diff --git a/src/lib/server/cras-artifacts.ts b/src/lib/server/cras-artifacts.ts new file mode 100644 index 0000000..7556584 --- /dev/null +++ b/src/lib/server/cras-artifacts.ts @@ -0,0 +1,309 @@ +/** + * 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 { + 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 { + 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): 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 { + 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 { + 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 { + 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; +} + +/** + * Poda la caché conservando las versiones indicadas y las más recientes hasta el límite. + * + * `keepVersions` recibe 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 { + const limit = keepVersions(); + const pinned = new Set(pinnedVersions.filter(isSafeVersion)); + const usage = await cacheUsage(); + + // Orden por versión descendente: las más nuevas se conservan. + const candidates = usage.versions + .map((v) => v.version) + .sort((a, b) => (compareVersions(b, a) ?? 0)); + + const removed: string[] = []; + let kept = 0; + for (const version of candidates) { + if (pinned.has(version)) continue; + kept += 1; + if (kept <= limit) continue; + 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: [...pinned] } + }); + } + return removed; +} diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts new file mode 100644 index 0000000..f7febd5 --- /dev/null +++ b/src/lib/server/cras-install.test.ts @@ -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 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 '' | 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'); + }); +}); diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts new file mode 100644 index 0000000..522295b --- /dev/null +++ b/src/lib/server/cras-install.ts @@ -0,0 +1,920 @@ +/** + * 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, + 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 { + 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((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 { + 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. + const runId = await startInstallRun({ + restoreTargetId: target.id, + releaseId: release.id, + version: release.version, + platform: release.platform, + mode: request.mode, + startedBy: request.startedBy + }); + + 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 { + 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 { + // 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 { + 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 { + // --- 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 { + 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 { + // 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 { + // --- 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 { + 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 { + 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; +} diff --git a/src/lib/server/cras-releases.ts b/src/lib/server/cras-releases.ts new file mode 100644 index 0000000..5a8fbf6 --- /dev/null +++ b/src/lib/server/cras-releases.ts @@ -0,0 +1,610 @@ +/** + * 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + 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; +} + +/** + * 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 { + 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 Error( + `Ya hay una instalación en curso para este servidor (run #${running.rows[0].id})` + ); + } + + 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 { + 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, + errorMessage: string | null = null +): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/lib/server/cras-sync.test.ts b/src/lib/server/cras-sync.test.ts new file mode 100644 index 0000000..7867e38 --- /dev/null +++ b/src/lib/server/cras-sync.test.ts @@ -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' + ); + }); +}); diff --git a/src/lib/server/cras-sync.ts b/src/lib/server/cras-sync.ts new file mode 100644 index 0000000..1e31e59 --- /dev/null +++ b/src/lib/server/cras-sync.ts @@ -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 { + 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---.' + }); + 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; +} diff --git a/src/lib/server/cras-verify.test.ts b/src/lib/server/cras-verify.test.ts new file mode 100644 index 0000000..2bbf883 --- /dev/null +++ b/src/lib/server/cras-verify.test.ts @@ -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 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'); + } + }); +}); diff --git a/src/lib/server/cras-verify.ts b/src/lib/server/cras-verify.ts new file mode 100644 index 0000000..07a7384 --- /dev/null +++ b/src/lib/server/cras-verify.ts @@ -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 { + const run = async (command: string): Promise => { + 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=, 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 { + 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 { + 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 { + 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 + }; +} diff --git a/src/lib/server/gitea-packages.test.ts b/src/lib/server/gitea-packages.test.ts new file mode 100644 index 0000000..1cda4c0 --- /dev/null +++ b/src/lib/server/gitea-packages.test.ts @@ -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) { + 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).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(); + }); +}); diff --git a/src/lib/server/gitea-packages.ts b/src/lib/server/gitea-packages.ts new file mode 100644 index 0000000..0c82fe5 --- /dev/null +++ b/src/lib/server/gitea-packages.ts @@ -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 { + 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(url: string): Promise { + 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 { + const pkg = packageName(); + const url = + `${baseUrl()}/api/v1/packages/${encodeURIComponent(owner())}` + + `?type=generic&q=${encodeURIComponent(pkg)}&limit=100`; + + const raw = await getJson(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 { + const url = + `${baseUrl()}/api/v1/packages/${encodeURIComponent(owner())}/generic/` + + `${encodeURIComponent(packageName())}/${encodeURIComponent(version)}/files`; + + const raw = await getJson(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; + 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 { + 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 + }; +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 6f5ecf7..4fe6997 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -25,7 +25,9 @@ import { deletePortalUser, listRestoreTargets, listRestoredRestoreJobLogs, - listFailedRestoreJobLogs + listFailedRestoreJobLogs, + dismissRestoreJobLogs, + undismissRestoreJobLogs } from '$lib/server/controldesk-pg'; import { listAdditionalEmails, @@ -57,6 +59,21 @@ function parseRestoreTargetId(formData: FormData): number | null { return Number.isInteger(n) && n > 0 ? n : null; } +/** + * IDs de la bitácora que llegan del formulario como lista separada por comas. Se sanean a + * enteros positivos aquí y otra vez en la capa de datos: el valor viene del navegador. + */ +function parseIdList(raw: FormDataEntryValue | null): number[] { + if (typeof raw !== 'string' || !raw.trim()) return []; + const ids = raw + .split(',') + .map((part) => Number(part.trim())) + .filter((n) => Number.isInteger(n) && n > 0); + // Tope defensivo: las vistas cargan a lo más 200 filas, así que una lista mucho mayor + // significa que alguien está armando la petición a mano. + return Array.from(new Set(ids)).slice(0, 500); +} + /** Verifica que el solicitante sea administrador (autorización en backend, no solo UI). */ async function isAdmin(cookies: import('@sveltejs/kit').Cookies): Promise { const token = cookies.get('session_token'); @@ -110,7 +127,11 @@ function withTimeout(promise: Promise, ms: number, message: string): Promi // SQL sano (<2s), nunca se dispara. Configurable por env. const SQL_LOAD_TIMEOUT_MS = Number(env.PANEL_SQL_LOAD_TIMEOUT_MS) || 12000; -export const load: PageServerLoad = async ({ cookies }) => { +export const load: PageServerLoad = async ({ cookies, url }) => { + // "Mostrar descartados" va por query param y no por estado de cliente: así sobrevive a un + // refresh y a compartir la URL, igual que ?view=. + const includeDismissed = url.searchParams.get('dismissed') === '1'; + // 1. Auth Check - Verificar token JWT const token = cookies.get('session_token'); if (!token) { @@ -333,8 +354,8 @@ export const load: PageServerLoad = async ({ cookies }) => { // `errors.restores` para avisar en la UI en vez de degradar a "sin registros" en silencio. try { [restoredBackups, failedRestores] = await Promise.all([ - listRestoredRestoreJobLogs(200), - listFailedRestoreJobLogs(200) + listRestoredRestoreJobLogs(200, includeDismissed), + listFailedRestoreJobLogs(200, includeDismissed) ]); } catch (e: any) { console.error('Error cargando inventario de restauraciones:', e); @@ -407,6 +428,7 @@ export const load: PageServerLoad = async ({ cookies }) => { backupFiles, restoredBackups, failedRestores, + includeDismissed, clientsData, alertsData, basesDeDatosList, @@ -555,6 +577,72 @@ export const actions: Actions = { } }, + /** + * Descarta registros de la bitácora: los saca de las vistas sin borrarlos. + * + * Recibe los IDs que el operador tenía en pantalla (posiblemente filtrados por el + * buscador), no un filtro, para que lo descartado sea exactamente lo que vio. + * + * Devuelve el conteo REAL de filas afectadas y no la cantidad de IDs recibidos: repetir la + * acción debe reportar 0 en lugar de volver a contar lo ya descartado. + */ + dismissRestores: async ({ request, cookies }) => { + // Guard explícito: este archivo tiene el helper y el comentario de "autorización en + // backend, no solo UI", pero varias de sus actions no lo llaman. + if (!(await isAdmin(cookies))) { + return { success: false, message: 'Requiere permisos de administrador.' }; + } + try { + const formData = await request.formData(); + const ids = parseIdList(formData.get('ids')); + if (ids.length === 0) { + return { success: false, message: 'No se recibió ningún registro que descartar.' }; + } + + const session = verifyToken(cookies.get('session_token') ?? ''); + const user = session ? await getUserById(session.userId) : null; + const affected = await dismissRestoreJobLogs(ids, user?.username ?? null); + + return { + success: true, + dismissed: affected, + dismissedIds: ids, + message: + affected === 0 + ? 'No había registros pendientes de descartar.' + : `${affected} registro(s) descartado(s). Siguen en la bitácora del servidor.` + }; + } catch (e: any) { + console.error('Error descartando registros de restauración:', e); + return { success: false, message: e.message }; + } + }, + + /** Revierte un descarte (el botón "Deshacer" del aviso de éxito). */ + undismissRestores: async ({ request, cookies }) => { + if (!(await isAdmin(cookies))) { + return { success: false, message: 'Requiere permisos de administrador.' }; + } + try { + const formData = await request.formData(); + const ids = parseIdList(formData.get('ids')); + if (ids.length === 0) { + return { success: false, message: 'No se recibió ningún registro que restaurar.' }; + } + const affected = await undismissRestoreJobLogs(ids); + return { + success: true, + message: + affected === 0 + ? 'Esos registros ya estaban en la lista.' + : `${affected} registro(s) devuelto(s) a la lista.` + }; + } catch (e: any) { + console.error('Error restaurando registros descartados:', e); + return { success: false, message: e.message }; + } + }, + deleteDatabase: async ({ request }) => { try { const formData = await request.formData(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index adaec1f..baee60a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -4,6 +4,7 @@ import { invalidateAll, goto, afterNavigate } from '$app/navigation'; import { page } from '$app/stores'; import { PANEL_VIEWS, isPanelView, type PanelViewKey } from '$lib/nav'; + import { filterRestored, filterFailed } from '$lib/restore-filter'; import AppShell from '$lib/components/AppShell.svelte'; type KitActionPayload = { success?: boolean; message?: string }; @@ -131,6 +132,8 @@ let scrollRootAlerts = $state(null); let scrollRootDbMgmt = $state(null); let scrollRootUsuarios = $state(null); + let scrollRootRestored = $state(null); + let scrollRootFailed = $state(null); async function fillScrollPortUntilOverflow( el: HTMLElement | null, @@ -168,6 +171,8 @@ let visibleDatabases = $state(PAGE_SIZE); let visibleMain = $state(PAGE_SIZE); let visibleAlerts = $state(PAGE_SIZE); + let visibleRestored = $state(PAGE_SIZE); + let visibleFailed = $state(PAGE_SIZE); // Buscadores let mainSearch = $state(''); @@ -231,6 +236,73 @@ // ---- Gestión de usuarios portal (PostgreSQL a24c.portal_users) ---- let usuariosList = $state(untrack(() => (data.usuariosList ?? []) as any[])); let usuariosSearch = $state(''); + let restoredSearch = $state(''); + let failedSearch = $state(''); + + // ---- Descartar registros de la bitácora ----------------------------------------- + // Descartar NO borra: saca la fila de estas vistas pero la conserva, así los contadores de + // /servidores-restauracion y la bitácora por servidor no se mueven. + let confirmDismiss = $state<{ ids: number[]; label: string } | null>(null); + let dismissing = $state(false); + /** IDs del último descarte, para ofrecer Deshacer. */ + let lastDismissed = $state([]); + let dismissNotice = $state(null); + let dismissError = $state(null); + + const includeDismissed = $derived(data.includeDismissed ?? false); + + /** + * Descarta (o deshace) por POST a la action. Se manda la lista de IDs y se confía en el + * conteo que devuelve el servidor, no en la longitud de la lista: si algo ya estaba + * descartado, el mensaje debe decir la verdad. + */ + async function submitDismiss(ids: number[], mode: 'dismiss' | 'undismiss' = 'dismiss') { + if (ids.length === 0) return; + dismissing = true; + try { + const body = new FormData(); + body.set('ids', ids.join(',')); + const res = await fetch(mode === 'dismiss' ? '?/dismissRestores' : '?/undismissRestores', { + method: 'POST', + body + }); + const result = parseKitAction(await res.text()); + const errMsg = kitActionErrorMessage(res, result); + if (errMsg) { + dismissError = errMsg; + dismissNotice = null; + return; + } + const payload = + result?.type === 'success' ? (result.data as Record | undefined) : undefined; + if (payload?.success === false) { + dismissError = String(payload.message ?? 'No se pudo completar la acción.'); + dismissNotice = null; + return; + } + + dismissError = null; + // El conteo lo dice el servidor, no la longitud de la lista: si algo ya estaba + // descartado el mensaje debe reflejarlo. + dismissNotice = String(payload?.message ?? 'Listo.'); + lastDismissed = mode === 'dismiss' ? ((payload?.dismissedIds as number[]) ?? []) : []; + await invalidateAll(); + } catch (e) { + dismissError = e instanceof Error ? e.message : String(e); + dismissNotice = null; + } finally { + dismissing = false; + confirmDismiss = null; + } + } + + function toggleDismissed() { + // Va por query param para que sobreviva al refresh, igual que ?view=. + const url = new URL($page.url); + if (includeDismissed) url.searchParams.delete('dismissed'); + else url.searchParams.set('dismissed', '1'); + goto(url, { replaceState: true, keepFocus: true, noScroll: true }); + } let visibleUsuarios = $state(PAGE_SIZE); let showUsuarioModal = $state(false); @@ -790,6 +862,11 @@ }); }; + // Buscadores de las dos vistas de restauraciones. El filtrado vive en $lib/restore-filter + // para poder probarse; los otros seis filtros del dashboard están inline y sin prueba. + const restoredRowsLive = $derived.by(() => filterRestored(restoredBackups, restoredSearch)); + const failedRowsLive = $derived.by(() => filterFailed(failedRestores, failedSearch)); + const mainRowsLive = $derived.by(() => filterMainRows()); const backupRowsLive = $derived.by(() => filterBackups()); const clientsTableRowsLive = $derived.by(() => filterClients()); @@ -811,6 +888,38 @@ }) ); }); + $effect(() => { + if (activeView !== 'restored') return; + void restoredRowsLive.length; + void restoredSearch; + void visibleRestored; + void tick().then(() => + fillScrollPortUntilOverflow( + scrollRootRestored, + restoredRowsLive.length, + () => visibleRestored, + (n) => { + visibleRestored = n; + } + ) + ); + }); + $effect(() => { + if (activeView !== 'failed') return; + void failedRowsLive.length; + void failedSearch; + void visibleFailed; + void tick().then(() => + fillScrollPortUntilOverflow( + scrollRootFailed, + failedRowsLive.length, + () => visibleFailed, + (n) => { + visibleFailed = n; + } + ) + ); + }); $effect(() => { if (activeView !== 'backups') return; void backupRowsLive.length; @@ -1012,6 +1121,8 @@ else if (activeView === 'backups') visibleBackups = PAGE_SIZE; else if (activeView === 'clients') visibleClients = PAGE_SIZE; else if (activeView === 'alerts') visibleAlerts = PAGE_SIZE; + else if (activeView === 'restored') visibleRestored = PAGE_SIZE; + else if (activeView === 'failed') visibleFailed = PAGE_SIZE; else if (activeView === 'databases') { visibleDatabases = PAGE_SIZE; visibleUsuarios = PAGE_SIZE; @@ -1022,6 +1133,14 @@ void mainSearch; visibleMain = PAGE_SIZE; }); + $effect(() => { + void restoredSearch; + visibleRestored = PAGE_SIZE; + }); + $effect(() => { + void failedSearch; + visibleFailed = PAGE_SIZE; + }); $effect(() => { void backupSearch; visibleBackups = PAGE_SIZE; @@ -1570,9 +1689,48 @@ {data.errors.restores} {/if} -
+
+ + Mostrando {Math.min(restoredRowsLive.length, visibleRestored)} + de {restoredRowsLive.length} + {#if restoredRowsLive.length !== restoredBackups.length} + (filtradas de {restoredBackups.length}) + {/if} + +
+
+ + search + + + {#if restoredSearch} + + {/if} +
+
+
+
+ tryExpandVisibleOnScroll(e.currentTarget, restoredRowsLive.length, visibleRestored, (n) => { + visibleRestored = n; + })} + class="bg-white shadow-sm ring-1 ring-slate-200 {TABLE_SCROLL_WRAPPER_CLASS}" + > - + @@ -1583,8 +1741,8 @@ - {#each restoredBackups as r (r.id)} - + {#each sliceVisible(restoredRowsLive, visibleRestored) as r (r.id)} + {/each} @@ -1631,9 +1793,95 @@ {data.errors.restores} {/if} -
+ {#if dismissError} +
+ {dismissError} +
+ {/if} + {#if dismissNotice} +
+ {dismissNotice} + {#if lastDismissed.length > 0} + + {/if} +
+ {/if} +
+ + Mostrando {Math.min(failedRowsLive.length, visibleFailed)} + de {failedRowsLive.length} + {#if failedRowsLive.length !== failedRestores.length} + (filtradas de {failedRestores.length}) + {/if} + +
+ + {#if data.currentUser?.es_admin && failedRowsLive.length > 0} + + {/if} +
+ + search + + + {#if failedSearch} + + {/if} +
+
+
+
+ tryExpandVisibleOnScroll(e.currentTarget, failedRowsLive.length, visibleFailed, (n) => { + visibleFailed = n; + })} + class="bg-white shadow-sm ring-1 ring-slate-200 {TABLE_SCROLL_WRAPPER_CLASS}" + >
Servidor Nodo / Cliente
{r.server_name ?? '—'} {r.node_key ?? r.db_name ?? '—'} @@ -1607,7 +1765,11 @@ {:else}
- No hay respaldos restaurados registrados. + {#if restoredSearch} + Ningún resultado para "{restoredSearch}". + {:else} + No hay respaldos restaurados registrados. + {/if}
- + @@ -1643,8 +1891,8 @@ - {#each failedRestores as f (f.id)} - + {#each sliceVisible(failedRowsLive, visibleFailed) as f (f.id)} + {:else} {/each} @@ -2289,6 +2566,49 @@ {/if} +{#if confirmDismiss} + {@const target = confirmDismiss} + +{/if} diff --git a/src/routes/api/restore/agent-sync/+server.ts b/src/routes/api/restore/agent-sync/+server.ts new file mode 100644 index 0000000..ff4bc3e --- /dev/null +++ b/src/routes/api/restore/agent-sync/+server.ts @@ -0,0 +1,67 @@ +/** + * POST /api/restore/agent-sync + * + * Dispara la sincronización del catálogo de versiones de CloudRestoreAS contra Gitea. + * Autenticado por token Bearer (CLOUDRESTORE_API_TOKEN), el mismo que usan los agentes: + * lo llama `packaging/scripts/publish-release.sh --notify-panel` justo después de publicar, + * para que la versión nueva aparezca sin esperar a que un admin abra /versiones-cras. + * + * NO activa nada: sincronizar solo registra lo que existe en Gitea. Activar una versión es + * una decisión explícita del operador desde la UI — si un sync activara sola la última, + * publicar se volvería desplegar. + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { checkServiceToken } from '$lib/server/service-auth'; +import { errorJson, newTraceId } from '$lib/server/api-error'; +import { logger } from '$lib/server/logger'; +import { GiteaError, isGiteaConfigured } from '$lib/server/gitea-packages'; +import { syncCrasReleasesFromGitea } from '$lib/server/cras-sync'; + +export const POST: RequestHandler = async ({ request }) => { + const traceId = newTraceId(); + + const auth = checkServiceToken(request); + if (!auth.ok) { + if (auth.status === 500) { + logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); + return errorJson(500, 'Servicio no configurado', traceId); + } + return errorJson(401, 'Token de servicio ausente o inválido', traceId); + } + + if (!isGiteaConfigured()) { + logger.error({ + trace_id: traceId, + message: 'GITEA_TOKEN no configurado; no se puede sincronizar el catálogo de CRAS' + }); + return errorJson(503, 'El panel no tiene configurado el acceso a Gitea', traceId); + } + + try { + const result = await syncCrasReleasesFromGitea(); + return json({ + ok: true, + versions: result.versions_seen, + registered: result.artifacts_registered, + discovered: result.artifacts_new, + skipped: result.skipped, + warnings: result.warnings, + trace_id: traceId + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ + trace_id: traceId, + message: 'Error sincronizando el catálogo de versiones de CRAS', + context: { error: message } + }); + if (err instanceof GiteaError) { + // Se propaga la causa real (token rechazado, Gitea caído) en lugar de un 500 + // genérico: el script de publicación muestra este código al operador. + const status = err.status === 404 ? 404 : err.status >= 500 ? 502 : 503; + return errorJson(status, `Gitea: ${err.message}`, traceId); + } + return errorJson(500, 'Error interno al sincronizar con Gitea', traceId); + } +}; diff --git a/src/routes/api/restore/instance-config/+server.ts b/src/routes/api/restore/instance-config/+server.ts index 5baf5c9..0483f5d 100644 --- a/src/routes/api/restore/instance-config/+server.ts +++ b/src/routes/api/restore/instance-config/+server.ts @@ -1,16 +1,27 @@ /** * POST /api/restore/instance-config * - * CloudRestoreAS reporta la carpeta de entrada vigente. Autenticado por token - * Bearer (CLOUDRESTORE_API_TOKEN). El panel solo consulta este dato en la UI admin; - * no hay escritura desde el navegador. + * CloudRestoreAS reporta su carpeta de entrada y la identidad del build instalado. + * Autenticado por token Bearer (CLOUDRESTORE_API_TOKEN). El panel solo consulta estos datos + * en la UI admin; no hay escritura desde el navegador. * * Body esperado: * { * "input_folder": "\\\\servidor\\compartida\\backups", + * "processed_folder": "\\\\servidor\\compartida\\Procesados", // opcional * "host_name": "WIN-RESTORE-01", - * "app_version": "1.0.0" + * "app_version": "1.1.0", + * "platform": "windows", // opcional; "windows" | "linux" + * "arch": "x86_64", // opcional + * "install_path": "/opt/cloudrestoreas", // opcional; carpeta del ejecutable + * "instance_key": "Alfa" // = restore_targets.name * } + * + * `platform` y `arch` identifican el build instalado y son la fuente autoritativa para + * decidir qué artefacto de CRAS le corresponde a este servidor al instalar o actualizar + * (a24c.cras_releases se llavea por version + platform + arch). Un agente anterior a 1.1.0 + * no las manda: se aceptan ausentes y el panel cae al texto libre de restore_targets.os + * para la primera instalación. */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; @@ -18,11 +29,16 @@ import { checkServiceToken } from '$lib/server/service-auth'; import { upsertCloudRestoreStatus } from '$lib/server/controldesk-pg'; import { errorJson, newTraceId } from '$lib/server/api-error'; import { logger } from '$lib/server/logger'; +import { CRAS_PLATFORMS, isSafeInstallPath, type CrasPlatform } from '$lib/cras-version'; interface InstanceConfigPayload { input_folder?: unknown; + processed_folder?: unknown; host_name?: unknown; app_version?: unknown; + platform?: unknown; + arch?: unknown; + install_path?: unknown; instance_key?: unknown; } @@ -32,6 +48,23 @@ function asOptionalString(value: unknown): string | null { return s ? s : null; } +/** + * Normaliza la plataforma reportada. Un valor desconocido se descarta (queda null) en lugar + * de guardarse: cras_releases.platform tiene un CHECK, así que una plataforma inventada no + * podría emparejar con ninguna versión y el servidor quedaría sin actualizaciones sin que + * nadie sepa por qué. + */ +function asPlatform(value: unknown): string | null { + const s = (asOptionalString(value) ?? '').toLowerCase(); + return (CRAS_PLATFORMS as readonly string[]).includes(s) ? s : null; +} + +/** El arch se limita a un token simple: forma parte de los nombres de artefacto. */ +function asArch(value: unknown): string | null { + const s = (asOptionalString(value) ?? '').toLowerCase(); + return /^[a-z0-9_]{1,20}$/.test(s) ? s : null; +} + export const POST: RequestHandler = async ({ request }) => { const traceId = newTraceId(); @@ -57,12 +90,43 @@ export const POST: RequestHandler = async ({ request }) => { } const instanceKey = asOptionalString(body.instance_key) ?? 'default'; + const platform = asPlatform(body.platform); + const arch = asArch(body.arch); + // La ruta se valida contra la plataforma: llega por red y el instalador la interpola en un + // comando de shell y en un script de PowerShell. Si no pasa, se guarda null y el panel cae + // al default de la plataforma en lugar de confiar en un valor sospechoso. + const rawInstallPath = asOptionalString(body.install_path); + const installPath = + rawInstallPath && platform && isSafeInstallPath(rawInstallPath, platform as CrasPlatform) + ? rawInstallPath + : null; + if (rawInstallPath && !installPath) { + logger.warn({ + trace_id: traceId, + message: 'install_path reportado por CloudRestoreAS rechazado por la validación', + context: { instance_key: instanceKey, install_path: rawInstallPath, platform } + }); + } + + // Se avisa cuando el agente mandó algo que no se pudo interpretar: descartarlo en + // silencio dejaría al servidor sin plataforma y sin pista de por qué. + if (body.platform !== undefined && body.platform !== null && platform === null) { + logger.warn({ + trace_id: traceId, + message: 'Plataforma reportada por CloudRestoreAS no reconocida; se ignora', + context: { instance_key: instanceKey, platform: String(body.platform) } + }); + } try { await upsertCloudRestoreStatus({ inputFolder, + processedFolder: asOptionalString(body.processed_folder), hostName: asOptionalString(body.host_name), appVersion: asOptionalString(body.app_version), + platform, + arch, + installPath, instanceKey }); return json({ ok: true, trace_id: traceId }); diff --git a/src/routes/versiones-cras/+page.server.ts b/src/routes/versiones-cras/+page.server.ts new file mode 100644 index 0000000..5694313 --- /dev/null +++ b/src/routes/versiones-cras/+page.server.ts @@ -0,0 +1,377 @@ +/** + * Versiones de CloudRestoreAS: catálogo publicado en Gitea e instalación remota por servidor. + * Solo administradores. + * + * Los binarios se publican en el registro de paquetes genéricos de Gitea desde el build local + * (`packaging/scripts/publish-release.sh`). Aquí se sincroniza el catálogo, se elige qué + * versión está activa por plataforma, y se instala o actualiza cada servidor por SSH. + * + * No hay subida de archivos por el navegador a propósito: los artefactos pesan ~270 MB y su + * origen de verdad es Gitea, no una carga manual. + */ +import { redirect, fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { env } from '$env/dynamic/private'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { + activateCrasRelease, + deactivateCrasRelease, + deleteCrasRelease, + getCrasReleaseById, + listCrasReleases, + listCrasTargetInventory, + listInstallRuns, + reapStaleInstallRuns +} from '$lib/server/cras-releases'; +import { + cacheUsage, + ensureCached, + isCached, + pruneCache, + removeCached, + ArtifactError +} from '$lib/server/cras-artifacts'; +import { syncCrasReleasesFromGitea } from '$lib/server/cras-sync'; +import { GiteaError, isGiteaConfigured, packageLocation } from '$lib/server/gitea-packages'; +import { installCrasOnTarget, InstallError } from '$lib/server/cras-install'; +import { logger } from '$lib/server/logger'; + +async function requireAdmin(cookies: import('@sveltejs/kit').Cookies) { + const token = cookies.get('session_token'); + if (!token) throw redirect(303, '/login'); + + const session = verifyToken(token); + if (!session) throw redirect(303, '/login'); + + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.es_admin) throw redirect(303, '/'); + return currentUser; +} + +/** + * URL base del panel que se siembra en el .env del agente. El agente vive en otro servidor, + * así que tiene que ser una URL alcanzable desde la red, no localhost. + */ +function panelApiUrl(): string { + return (env.PANEL_PUBLIC_URL || env.ORIGIN || '').trim(); +} + +function panelApiToken(): string { + return (env.CLOUDRESTORE_API_TOKEN || '').trim(); +} + +export const load: PageServerLoad = async ({ cookies }) => { + const currentUser = await requireAdmin(cookies); + let dbWarning: string | null = null; + + // Un reinicio del panel deja runs colgados en 'running' que bloquearían nuevas + // instalaciones en ese destino; se cierran al entrar a la pantalla. + try { + const reaped = await reapStaleInstallRuns(); + if (reaped > 0) { + logger.warn({ + message: 'Instalaciones de CRAS marcadas como interrumpidas al cargar la pantalla', + context: { count: reaped } + }); + } + } catch (e) { + console.error('Error cerrando instalaciones interrumpidas:', e); + } + + let releases: Awaited> = []; + try { + releases = await listCrasReleases(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + console.error('Error listando cras_releases:', e); + dbWarning = `No se pudo cargar el catálogo de versiones: ${msg}`; + } + + let inventory: Awaited> = []; + try { + inventory = await listCrasTargetInventory(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + console.error('Error armando el inventario de servidores:', e); + dbWarning = dbWarning ?? `No se pudo cargar el inventario de servidores: ${msg}`; + } + + let runs: Awaited> = []; + try { + runs = await listInstallRuns(20); + } catch (e) { + console.error('Error leyendo la bitácora de instalaciones:', e); + } + + // Qué versiones están en caché local: sin artefacto en disco la instalación primero + // tiene que bajar ~270 MB de Gitea, y conviene que se vea antes de empezar. + const cached: Record = {}; + for (const release of releases) { + try { + cached[release.id] = await isCached(release.version, release.file_name); + } catch { + cached[release.id] = false; + } + } + + let usage: Awaited> = { total_bytes: 0, versions: [] }; + try { + usage = await cacheUsage(); + } catch (e) { + console.error('Error midiendo la caché de artefactos:', e); + } + + // Avisos de configuración: sin estos datos la pantalla se ve bien pero no puede operar. + const configWarnings: string[] = []; + if (!isGiteaConfigured()) { + configWarnings.push( + 'GITEA_TOKEN no está configurado en el panel: no se puede sincronizar ni descargar versiones.' + ); + } + if (!panelApiToken()) { + configWarnings.push( + 'CLOUDRESTORE_API_TOKEN no está configurado: no se puede sembrar la configuración del agente al instalar.' + ); + } + if (!panelApiUrl()) { + configWarnings.push( + 'PANEL_PUBLIC_URL (u ORIGIN) no está configurado: el agente no sabría a qué URL reportar.' + ); + } + + return { + currentUser, + releases, + inventory, + runs, + cached, + usage, + dbWarning, + configWarnings, + giteaConfigured: isGiteaConfigured(), + packageLocation: packageLocation() + }; +}; + +function parseId(data: FormData, field: string): number | null { + const raw = data.get(field)?.toString().trim(); + const id = Number(raw); + return Number.isInteger(id) && id > 0 ? id : null; +} + +export const actions: Actions = { + /** Lee la API de paquetes de Gitea y registra las versiones que encuentre. */ + sync: async ({ cookies }) => { + await requireAdmin(cookies); + if (!isGiteaConfigured()) { + return fail(503, { error: 'El panel no tiene configurado GITEA_TOKEN.' }); + } + try { + const result = await syncCrasReleasesFromGitea(); + const parts = [ + `${result.versions_seen} versión(es) en Gitea`, + `${result.artifacts_registered} artefacto(s) registrados`, + `${result.artifacts_new} nuevo(s)` + ]; + return { + success: `Sincronización completa: ${parts.join(', ')}.`, + // Lo omitido se reporta en lugar de descartarse: un artefacto invisible se + // traduce en un servidor al que nunca se le ofrece la actualización. + skipped: result.skipped, + warnings: result.warnings + }; + } catch (e) { + const msg = e instanceof GiteaError ? e.message : e instanceof Error ? e.message : String(e); + return fail(502, { error: `No se pudo sincronizar con Gitea: ${msg}` }); + } + }, + + /** Marca una versión como la activa de su plataforma+arquitectura. */ + activate: async ({ cookies, request }) => { + await requireAdmin(cookies); + const data = await request.formData(); + const id = parseId(data, 'id'); + if (!id) return fail(400, { error: 'Versión inválida.' }); + + try { + const release = await activateCrasRelease(id); + if (!release) return fail(404, { error: 'La versión ya no existe.' }); + return { + success: `${release.version} (${release.platform}/${release.arch}) quedó como versión activa.` + }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return fail(500, { error: `No se pudo activar la versión: ${msg}` }); + } + }, + + deactivate: async ({ cookies, request }) => { + await requireAdmin(cookies); + const data = await request.formData(); + const id = parseId(data, 'id'); + if (!id) return fail(400, { error: 'Versión inválida.' }); + try { + await deactivateCrasRelease(id); + return { success: 'Versión desactivada.' }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return fail(500, { error: `No se pudo desactivar: ${msg}` }); + } + }, + + /** Descarga el artefacto a la caché local y verifica su sha256. */ + precache: async ({ cookies, request }) => { + await requireAdmin(cookies); + const data = await request.formData(); + const id = parseId(data, 'id'); + if (!id) return fail(400, { error: 'Versión inválida.' }); + + const release = await getCrasReleaseById(id); + if (!release) return fail(404, { error: 'La versión ya no existe.' }); + + try { + const result = await ensureCached( + release.version, + release.file_name, + release.sha256, + release.file_size + ); + return { + success: result.downloaded + ? `${release.file_name} descargado y verificado (${formatBytes(result.size)}).` + : `${release.file_name} ya estaba en caché.` + }; + } catch (e) { + const status = e instanceof ArtifactError ? e.status : 500; + const msg = e instanceof Error ? e.message : String(e); + return fail(status === 409 ? 409 : 502, { error: msg }); + } + }, + + uncache: async ({ cookies, request }) => { + await requireAdmin(cookies); + const data = await request.formData(); + const id = parseId(data, 'id'); + if (!id) return fail(400, { error: 'Versión inválida.' }); + + const release = await getCrasReleaseById(id); + if (!release) return fail(404, { error: 'La versión ya no existe.' }); + try { + await removeCached(release.version, release.file_name); + return { success: `${release.file_name} eliminado de la caché local.` }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return fail(500, { error: `No se pudo liberar la caché: ${msg}` }); + } + }, + + /** Poda la caché conservando las versiones activas y las más recientes. */ + prune: async ({ cookies }) => { + await requireAdmin(cookies); + try { + const releases = await listCrasReleases(); + const pinned = releases.filter((r) => r.is_active).map((r) => r.version); + const removed = await pruneCache(pinned); + return { + success: removed.length + ? `Se liberaron ${removed.length} versión(es) de la caché: ${removed.join(', ')}.` + : 'No había versiones para liberar.' + }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return fail(500, { error: `No se pudo podar la caché: ${msg}` }); + } + }, + + /** + * Borra una versión del catálogo. No toca Gitea: los paquetes genéricos son inmutables + * y siguen siendo el respaldo, así que un sync posterior la vuelve a registrar. + */ + deleteRelease: async ({ cookies, request }) => { + await requireAdmin(cookies); + const data = await request.formData(); + const id = parseId(data, 'id'); + if (!id) return fail(400, { error: 'Versión inválida.' }); + + const release = await getCrasReleaseById(id); + if (!release) return fail(404, { error: 'La versión ya no existe.' }); + if (release.is_active) { + return fail(409, { + error: 'No se puede borrar la versión activa. Activa otra antes de quitarla.' + }); + } + try { + await deleteCrasRelease(id); + return { + success: `${release.version} (${release.platform}) quitada del catálogo. Sigue publicada en Gitea.` + }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return fail(500, { error: `No se pudo quitar del catálogo: ${msg}` }); + } + }, + + /** + * Instala o actualiza CRAS en un servidor. Bloqueante: puede tardar varios minutos + * subiendo ~270 MB. La UI hace polling de cras_install_runs.steps para el progreso. + */ + install: async ({ cookies, request }) => { + const currentUser = await requireAdmin(cookies); + const data = await request.formData(); + const targetId = parseId(data, 'target_id'); + const releaseId = parseId(data, 'release_id'); + const modeRaw = data.get('mode')?.toString().trim(); + const autostartRaw = data.get('autostart')?.toString().trim(); + + if (!targetId || !releaseId) { + return fail(400, { error: 'Servidor o versión inválidos.' }); + } + const mode = modeRaw === 'update' ? 'update' : 'install'; + const autostart = + autostartRaw === 'desktop' ? 'desktop' : autostartRaw === 'none' ? 'none' : 'service'; + + const apiUrl = panelApiUrl(); + const apiToken = panelApiToken(); + if (!apiUrl || !apiToken) { + return fail(500, { + error: + 'Falta configurar PANEL_PUBLIC_URL y CLOUDRESTORE_API_TOKEN en el panel: ' + + 'sin ellos el agente instalado no sabría a dónde reportar.' + }); + } + + try { + const outcome = await installCrasOnTarget({ + restoreTargetId: targetId, + releaseId, + mode, + startedBy: currentUser.username, + panelApiUrl: apiUrl, + panelApiToken: apiToken, + autostart + }); + if (!outcome.ok) { + return fail(502, { + error: `La instalación de ${outcome.version} falló: ${outcome.error}`, + runId: outcome.runId + }); + } + return { + success: `${outcome.version} instalada. El servidor reportará su versión en el próximo ciclo.`, + runId: outcome.runId + }; + } catch (e) { + const status = e instanceof InstallError ? e.status : 500; + const msg = e instanceof Error ? e.message : String(e); + return fail(status === 404 || status === 409 ? status : 500, { error: msg }); + } + } +}; + +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`; +} diff --git a/src/routes/versiones-cras/+page.svelte b/src/routes/versiones-cras/+page.svelte new file mode 100644 index 0000000..90dace9 --- /dev/null +++ b/src/routes/versiones-cras/+page.svelte @@ -0,0 +1,955 @@ + + + +
+ + {#if data.dbWarning} +
+ error_outline + {data.dbWarning} +
+ {/if} + + {#each data.configWarnings ?? [] as warning} +
+ warning_amber + {warning} +
+ {/each} + + {#if form?.error} +
+ error_outline + {form.error} +
+ {/if} + {#if form?.success} +
+ check_circle + {form.success} +
+ {/if} + + + {#if form?.skipped?.length} +
+

Archivos omitidos en la sincronización:

+
    + {#each form.skipped as item} +
  • {item.file_name} — {item.reason}
  • + {/each} +
+
+ {/if} + {#if form?.warnings?.length} +
+
    + {#each form.warnings as warning}
  • {warning}
  • {/each} +
+
+ {/if} + + +
+
+
+

Catálogo de versiones

+

+ Los binarios se publican en Gitea desde el build local + (build-all.sh --publish). El panel los descubre + aquí, los descarga verificando su sha256, y los instala por SSH. +

+

+ inventory_2 + {data.packageLocation} +

+
+
{ + busyAction = 'sync'; + return async ({ update }) => { + await update(); + busyAction = null; + }; + }} + > + + +
+ +
+ {releases.length} artefacto(s) en catálogo + {activeReleases.length} activo(s) + 0} class:font-semibold={pendingUpdates > 0}> + {pendingUpdates} servidor(es) con actualización pendiente + + + Caché local: {formatBytes(data.usage?.total_bytes)} + en {data.usage?.versions?.length ?? 0} versión(es) + + {#if (data.usage?.versions?.length ?? 0) > 0} +
{ + busyAction = 'prune'; + return async ({ update }) => { + await update(); + busyAction = null; + }; + }} + > + + + {/if} +
+
+ + +
+
Servidor Base / Archivo
{f.server_name ?? '—'} {f.db_name ?? '—'} @@ -1661,12 +1909,41 @@ {:else} {/if} + {#if data.currentUser?.es_admin} + {#if f.dismissed_at} + + {:else} + + {/if} + {/if}
- No hay restores fallidos registrados. + {#if failedSearch} + Ningún resultado para "{failedSearch}". + {:else} + No hay restores fallidos registrados. + {/if}
+ + + + + + + + + + + + + {#each releases as release (release.id)} + + + + + + + + + + {:else} + + + + {/each} + +
VersiónPlataformaTamañosha256PublicadaEstadoAcciones
+ {release.version} +
{release.file_name}
+
+ {platformLabel(release.platform)} + / {release.arch} + {formatBytes(release.file_size)} + {#if release.sha256} + + {release.sha256.slice(0, 12)}… + + {:else} + + sin hash + + {/if} + {formatDate(release.published_at)} +
+ {#if release.is_active} + + Activa + + {/if} + {#if cached[release.id]} + + En caché + + {:else} + + No descargada + + {/if} +
+
+
+ {#if !release.is_active} +
+ + +
+ {:else} +
+ + +
+ {/if} + + {#if cached[release.id]} +
+ + +
+ {:else} +
{ + busyAction = `precache-${release.id}`; + return async ({ update }) => { + await update(); + busyAction = null; + }; + }} + > + + +
+ {/if} + + +
+
+ {#if data.giteaConfigured} + No hay versiones registradas. Usa Sincronizar con Gitea + para descubrir lo que ya esté publicado. + {:else} + Configura GITEA_TOKEN en el panel para + poder leer el catálogo de Gitea. + {/if} +
+
+ + +
+

Servidores de restauración

+
+ + + + + + + + + + + + + {#each inventory as target (target.restore_target_id)} + + + + + + + + + {:else} + + + + {/each} + +
ServidorPlataformaInstaladaActivaÚltimo reporteAcciones
+ {target.name} + {#if target.ssh_host} +
+ {target.ssh_username}@{target.ssh_host} +
+ {/if} +
+ {platformLabel(target.platform)} + {#if target.platform} + / {target.arch} + {/if} + {#if !target.reported_platform && target.platform} +
+ (según SO capturado) +
+ {/if} +
+ {target.installed_version ?? '—'} + + {#if target.active_version} + {target.active_version} + {#if target.update_available} + + actualización disponible + + {/if} + {:else if target.platform} + sin versión activa + {:else} + plataforma sin determinar + {/if} + {formatDate(target.reported_at)} + {#if target.running_install_id} + + progress_activity + instalación en curso + + {:else} + + + {/if} +
+ No hay servidores de restauración dados de alta. +
+
+
+ + + {#if runs.length} +
+

Últimas instalaciones

+
+ + + + + + + + + + + + + {#each runs as run (run.id)} + + + + + + + + + {/each} + +
ServidorVersiónModoEstadoInicioPor
{run.target_name ?? '—'}{run.version ?? '—'} + {run.mode === 'update' ? 'Actualización' : 'Instalación'} + + {#if run.status === 'completed'} + completada + {:else if run.status === 'failed'} + + fallida + + {:else} + en curso + {/if} + {formatDate(run.started_at)}{run.started_by ?? '—'}
+
+
+ {/if} +
+ + +{#if verifyResult || verifyError} + +{/if} + + +{#if installTarget} + {@const target = installTarget} + +{/if} + + +{#if confirmDelete} + {@const release = confirmDelete} + +{/if} + diff --git a/src/routes/versiones-cras/install-runs/+server.ts b/src/routes/versiones-cras/install-runs/+server.ts new file mode 100644 index 0000000..7baf869 --- /dev/null +++ b/src/routes/versiones-cras/install-runs/+server.ts @@ -0,0 +1,68 @@ +/** + * GET /versiones-cras/install-runs?runId= + * GET /versiones-cras/install-runs?targetId= → el run más reciente de ese servidor + * + * Progreso de una instalación remota. La UI lo consulta por polling mientras la acción + * `install` sigue corriendo: una instalación sube ~270 MB y tarda minutos, así que el + * operador necesita ver en qué paso va en lugar de un spinner opaco. + * + * La consulta por `targetId` existe porque la acción es bloqueante: el navegador no conoce + * el runId hasta que la instalación termina, así que durante la espera solo puede preguntar + * por el servidor. + * + * Autenticado con la cookie de admin, igual que la página (este repo no tiene + * hooks.server.ts y repite el guard por ruta). + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { getInstallRun, getLatestInstallRunForTarget } from '$lib/server/cras-releases'; +import { errorJson, newTraceId } from '$lib/server/api-error'; +import { logger } from '$lib/server/logger'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const traceId = newTraceId(); + + const token = cookies.get('session_token'); + const session = token ? verifyToken(token) : null; + if (!session) return errorJson(401, 'Sesión no válida', traceId); + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.es_admin) { + return errorJson(403, 'Requiere permisos de administrador', traceId); + } + + const runId = Number(url.searchParams.get('runId')); + const targetId = Number(url.searchParams.get('targetId')); + const byRun = Number.isInteger(runId) && runId > 0; + const byTarget = Number.isInteger(targetId) && targetId > 0; + if (!byRun && !byTarget) { + return errorJson(400, 'Indica runId o targetId', traceId); + } + + try { + const run = byRun ? await getInstallRun(runId) : await getLatestInstallRunForTarget(targetId); + if (!run) return errorJson(404, 'No hay instalaciones registradas', traceId); + return json({ + id: run.id, + status: run.status, + mode: run.mode, + version: run.version, + platform: run.platform, + target_name: run.target_name ?? null, + steps: run.steps ?? [], + error_message: run.error_message, + started_at: run.started_at, + finished_at: run.finished_at, + trace_id: traceId + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ + trace_id: traceId, + message: 'Error leyendo el progreso de una instalación de CRAS', + context: { run_id: runId, error: message } + }); + return errorJson(500, 'Error interno al leer el progreso', traceId); + } +}; diff --git a/src/routes/versiones-cras/verify/+server.ts b/src/routes/versiones-cras/verify/+server.ts new file mode 100644 index 0000000..c5435cd --- /dev/null +++ b/src/routes/versiones-cras/verify/+server.ts @@ -0,0 +1,59 @@ +/** + * GET /versiones-cras/verify?targetId= + * + * Sonda en vivo de un servidor de restauración: contesta "¿está disponible AHORA?" con el + * detalle de POR QUÉ cuando no lo está, y el remedio cuando hay uno. + * + * Es distinto de `cloudrestore_status.reported_at`, que solo dice cuándo el agente arrancó o + * guardó configuración (no es un heartbeat). Un agente sano de hace una semana y uno muerto de + * hace una semana tienen el mismo timestamp; esta sonda los distingue. + * + * Solo lee del destino: no instala, no escribe ni reinicia nada, así que es seguro llamarla + * en cualquier momento. Autenticada con la cookie de admin, igual que la página. + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { verifyCrasTarget } from '$lib/server/cras-verify'; +import { errorJson, newTraceId } from '$lib/server/api-error'; +import { logger } from '$lib/server/logger'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const traceId = newTraceId(); + + const token = cookies.get('session_token'); + const session = token ? verifyToken(token) : null; + if (!session) return errorJson(401, 'Sesión no válida', traceId); + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.es_admin) { + return errorJson(403, 'Requiere permisos de administrador', traceId); + } + + const targetId = Number(url.searchParams.get('targetId')); + if (!Number.isInteger(targetId) || targetId <= 0) { + return errorJson(400, 'targetId inválido', traceId); + } + + try { + const result = await verifyCrasTarget(targetId); + logger.info({ + trace_id: traceId, + message: 'Verificación de restaurador', + context: { + target: result.name, + diagnosis: result.diagnosis, + started_by: currentUser.username + } + }); + return json({ ...result, trace_id: traceId }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ + trace_id: traceId, + message: 'Error verificando un restaurador', + context: { target_id: targetId, error: message } + }); + return errorJson(500, `Error interno al verificar: ${message}`, traceId); + } +};