/** * 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, prunePlan, removeCached, ArtifactError, type PrunePlan } from '$lib/server/cras-artifacts'; import { installPlatformVerdict, isInsideWorkFolder, isSafeInstallPath, platformLabel } from '$lib/cras-version'; import { setCloudRestoreInstallPath } from '$lib/server/controldesk-pg'; import { syncCrasReleasesFromGitea } from '$lib/server/cras-sync'; import { GiteaError, giteaTokenProblem, 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(); } /** * Aviso cuando PANEL_PUBLIC_URL apunta a un origen distinto del que sirve el panel. * * Esta URL se siembra en el config/.env del agente, y el instalador la reescribe en CADA * actualización, no solo al instalar. Una URL equivocada no falla al capturarla: rompe el servidor * la próxima vez que se actualice, y de golpe —el agente deja de reportar y de resolver rutas de * restauración—. En producción le faltaba el puerto y el 443 de ese host lo sirve otro servicio, * así que todo lo que pidiera el agente habría caído en un 404 ajeno. * * Se compara contra ORIGIN porque el navegador ya lo validó: adapter-node rechaza los POST cuyo * Origin no cuadre, así que si la pantalla responde, ORIGIN es el origen real del panel. */ function panelUrlOriginMismatch(): string | null { const declared = (env.PANEL_PUBLIC_URL || '').trim(); const origin = (env.ORIGIN || '').trim(); if (!declared || !origin) return null; let declaredUrl: URL; let originUrl: URL; try { declaredUrl = new URL(declared); originUrl = new URL(origin); } catch { return `PANEL_PUBLIC_URL no es una URL válida (${declared}): el agente no sabría a dónde reportar.`; } // `host` incluye el puerto, que es justo lo que se omitió en producción. if (declaredUrl.host === originUrl.host) return null; return ( `PANEL_PUBLIC_URL (${declaredUrl.origin}) no coincide con el origen del panel ` + `(${originUrl.origin}). Es la URL que se siembra en el agente al instalar y al actualizar, ` + 'así que apuntaría a otro servicio. Corrige el puerto o comenta PANEL_PUBLIC_URL para que ' + 'tome ORIGIN.' ); } 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); } // Qué se borraría al podar, con el mismo criterio con que se ejecuta: el control de // "Liberar" tiene que decir cuánto libera antes de que el operador confirme, y quedar // apagado cuando no hay nada que liberar (con ≤3 versiones en caché no borra nada). let prune: PrunePlan = { versions: [], bytes: 0 }; try { prune = await prunePlan(releases.filter((r) => r.is_active).map((r) => r.version)); } catch (e) { console.error('Error calculando el plan de poda de la caché:', e); } // Avisos de configuración: sin estos datos la pantalla se ve bien pero no puede operar. const configWarnings: string[] = []; // Cubre el token ausente y el mal capturado. Antes solo se avisaba del ausente, así que un // valor con los `<>` de la plantilla solo se manifestaba como un 401 al presionar Sincronizar. const tokenProblem = giteaTokenProblem(); if (tokenProblem) { configWarnings.push(`${tokenProblem}: 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.' ); } const originMismatch = panelUrlOriginMismatch(); if (originMismatch) { configWarnings.push(originMismatch); } return { currentUser, releases, inventory, runs, cached, usage, prune, 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}` }); } }, /** * Fija a mano la ruta de instalación de un servidor. * * Un agente anterior a 1.1.0 no reporta su install_path, así que el panel cae al default de * plataforma y ACTUALIZAR aborta cuando la instalación vive en otra carpeta. Sin esta captura * ese servidor no se podría actualizar nunca: la ruta empieza a reportarse en 1.1.x, que es * justo lo que no se puede instalar. En cuanto el servidor quede en 1.1.x su propio reporte * sustituye este valor por la ruta real. */ setInstallPath: async ({ cookies, request }) => { const currentUser = await requireAdmin(cookies); const data = await request.formData(); const targetId = parseId(data, 'target_id'); const rawPath = data.get('install_path')?.toString().trim() ?? ''; if (!targetId) return fail(400, { error: 'Servidor inválido.' }); if (!rawPath) return fail(400, { error: 'Captura la ruta de instalación.' }); let target: Awaited>[number] | undefined; try { target = (await listCrasTargetInventory()).find( (t) => t.restore_target_id === targetId ); } catch (e) { const msg = e instanceof Error ? e.message : String(e); return fail(500, { error: `No se pudo leer el inventario de servidores: ${msg}` }); } if (!target) return fail(404, { error: 'El servidor de restauración ya no existe.' }); // Sin plataforma no hay con qué validar: las reglas de ruta son distintas en Windows y // en Linux, y aplicar las equivocadas dejaría pasar una ruta que el instalador no puede // usar. if (!target.platform) { return fail(422, { error: `No se conoce la plataforma de ${target.name}, y sin ella no se puede validar ` + 'la ruta. Captura el campo SO en Servidores de Restauración.' }); } if (!isSafeInstallPath(rawPath, target.platform)) { return fail(422, { error: `"${rawPath}" no es una ruta válida para ${platformLabel(target.platform)}. ` + 'Debe ser absoluta, sin comillas ni metacaracteres, sin ".." y sin rutas de red.' }); } // Se normaliza igual que effectiveInstallPath (cras-version.ts) para que el valor guardado // sea comparable con el que reporte el agente después y no queden dos formas del mismo path. const installPath = rawPath.replace(/[/\\]+$/, ''); // La raíz de una unidad pasa isSafeInstallPath pero no es un destino de instalación: ahí // el instalador aplicaría ACLs y borrados recursivos sobre todo el disco. Solo se puede // llegar por captura manual, porque el agente reporta la carpeta de su ejecutable. if (!installPath || /^[A-Za-z]:$/.test(installPath)) { return fail(422, { error: 'La ruta no puede ser la raíz de la unidad; usa una carpeta.' }); } if (isInsideWorkFolder(installPath, [target.input_folder, target.processed_folder])) { return fail(409, { error: `${installPath} está dentro de una carpeta de trabajo de ${target.name}. ` + 'Ahí el agente tomaría su propio binario por un respaldo a procesar.' }); } try { const updated = await setCloudRestoreInstallPath(target.name, installPath); if (!updated) { return fail(409, { error: `${target.name} no tiene ningún reporte del agente todavía, así que no hay ` + 'instancia a la que fijarle la ruta. Espera a que reporte por primera vez.' }); } logger.info({ message: 'Ruta de instalación de CRAS fijada a mano', context: { target: target.name, install_path: installPath, previous: target.reported_install_path, set_by: currentUser.username } }); return { success: `Ruta de ${target.name} fijada en ${installPath}. ` + 'Verifica antes de actualizar; cuando el servidor quede en 1.1.x el agente ' + 'reportará su ruta y este valor se corrige solo.' }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); return fail(500, { error: `No se pudo guardar la ruta: ${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 platformAck = data.get('platform_ack')?.toString() ?? ''; // Red de seguridad del formulario. Se valida ANTES de abrir el run porque la única guarda // que había —assertSystemMatches, dentro del instalador— ya corre con la sesión SSH // abierta: el operador se llevaba un run fallido en la bitácora en lugar de un aviso. const release = await getCrasReleaseById(releaseId); if (!release) { return fail(404, { error: 'La versión indicada no existe en el catálogo.' }); } let target: Awaited>[number] | undefined; try { target = (await listCrasTargetInventory()).find( (t) => t.restore_target_id === targetId ); } catch (e) { const msg = e instanceof Error ? e.message : String(e); return fail(500, { error: `No se pudo validar el servidor destino: ${msg}` }); } if (!target) { return fail(404, { error: 'El servidor de restauración ya no existe.' }); } if (target.running_install_id) { return fail(409, { error: `Ya hay una instalación en curso en ${target.name} ` + `(run #${target.running_install_id}). Espera a que termine antes de lanzar otra.` }); } const verdict = installPlatformVerdict(target.platform, release.platform, platformAck); if (verdict === 'mismatch') { return fail(409, { error: `${target.name} es ${platformLabel(target.platform)} y ${release.version} es ` + `${platformLabel(release.platform)}. Elige el artefacto de la plataforma correcta.` }); } if (verdict === 'needs-ack') { return fail(422, { error: `No se pudo determinar la plataforma de ${target.name}. Confirma en el ` + 'formulario que el artefacto corresponde al sistema del servidor, o captura ' + 'el campo SO en Servidores de Restauración.' }); } 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.' }); } // Se aborta antes de tocar el servidor: esta URL se escribe sobre el config/.env del // destino también al ACTUALIZAR, así que dejarla pasar rompería un agente que hoy sí // reporta. Es más barato corregir el .env del panel que reparar el del servidor. const originMismatch = panelUrlOriginMismatch(); if (originMismatch) { return fail(500, { error: originMismatch }); } 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`; }