From 7f9d502cb1007c110616b940c3070662db757f53 Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Mon, 6 Jul 2026 17:00:49 +0000 Subject: [PATCH] feat(env): update environment configuration and add SQL Server password handling (#15) - Added new environment variables for deduplication and SQL Server password encryption. - Updated docker-compose files to include SECRET_KEY and ENCRYPTION_KEY for compatibility with a24c. - Enhanced the navigation structure to reflect changes in admin-only views and report access. - Introduced new functions for handling SQL Server connections and database management, including parsing server addresses and listing user databases. This update improves security and functionality related to database management and user access control. Reviewed-on: https://git.aduanasoft.com/ADUANASOFT/PANEL_BASES_ANEXO24/pulls/15 Co-authored-by: AlexeerCT Co-committed-by: AlexeerCT --- .claude/settings.local.json | 11 +- .env.example | 24 +- docker-compose.prod.yml | 6 + docker-compose.yml | 2 + package-lock.json | 22 + package.json | 2 + src/lib/components/AppShell.svelte | 11 +- src/lib/nav.test.ts | 2 +- src/lib/nav.ts | 4 +- src/lib/server/controldesk-pg.ts | 40 ++ src/lib/server/crypto.ts | 39 +- src/lib/server/db-move.ts | 190 ++++++++ src/lib/server/dedup-databases.test.ts | 151 +++++++ src/lib/server/dedup-databases.ts | 320 ++++++++++++++ src/lib/server/fernet.test.ts | 74 ++++ src/lib/server/fernet.ts | 115 +++++ src/lib/server/mssql-nodes.test.ts | 34 +- src/lib/server/mssql-nodes.ts | 161 ++++++- src/lib/server/sftp-transfer.test.ts | 31 ++ src/lib/server/sftp-transfer.ts | 103 +++++ src/routes/+page.server.ts | 21 +- src/routes/+page.svelte | 4 +- src/routes/reportes/+page.server.ts | 2 + src/routes/reportes/excel/+server.ts | 16 +- .../depuracion/+page.server.ts | 116 +++++ .../depuracion/+page.svelte | 407 ++++++++++++++++++ 26 files changed, 1858 insertions(+), 50 deletions(-) create mode 100644 src/lib/server/db-move.ts create mode 100644 src/lib/server/dedup-databases.test.ts create mode 100644 src/lib/server/dedup-databases.ts create mode 100644 src/lib/server/fernet.test.ts create mode 100644 src/lib/server/fernet.ts create mode 100644 src/lib/server/sftp-transfer.test.ts create mode 100644 src/lib/server/sftp-transfer.ts create mode 100644 src/routes/servidores-restauracion/depuracion/+page.server.ts create mode 100644 src/routes/servidores-restauracion/depuracion/+page.svelte diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5a2a05a..ed5067e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,16 @@ "Bash(npm install:*)", "Bash(node -e \"require\\(''''c:/CPANEL/PANEL_BASES_ANEXO24/node_modules/exceljs''''\\)\")", "Bash(docker-compose up:*)", - "Bash(npx svelte-check *)" + "Bash(npx svelte-check *)", + "Bash(grep -rnE \"SERVER=|Server=|,{port}|:{port}|adjust_server_for_docker|conn_str|connection_string|pyodbc.connect|DRIVER=\" backend/api/v1/modules/scaii/contribuyente_legacy/service.py)", + "Bash(node_modules/.bin/vitest run *)", + "Bash(npm run *)", + "Bash(npx vitest *)", + "Bash(xargs -I{} sh -c 'echo \"--- {} ---\"; head -25 {}')", + "Bash(node -e \"require\\('archiver'\\)\")", + "Bash(node -e \"console.log\\(require\\('@types/archiver/package.json'\\).version\\)\")", + "Bash(grep -vE \"checkOrigin|deprecated|^$|svelte-kit sync|> \")", + "Bash(grep -vE \"checkOrigin|deprecated|^$|svelte-kit sync|^> \")" ] } } diff --git a/.env.example b/.env.example index c4933c4..7ce9eb9 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,18 @@ PANEL_MSSQL_USER=sa PANEL_MSSQL_PASSWORD=Clave.2025 # En contenedor Docker: reescribe localhost en server_name → host.docker.internal # PANEL_MSSQL_DOCKER=true +# Depuración de bases duplicadas: tolerancia de tamaño (fracción 0–1) para marcar una base como +# segura para borrar del servidor viejo. El nuevo debe pesar >= (1 - tolerancia) del viejo. Default 0.2. +# PANEL_DEDUP_SIZE_TOLERANCE=0.2 +# "Mandar al nuevo": carpeta donde el SQL viejo escribe el .bak (default: data_folder del servidor). +# PANEL_DEDUP_BACKUP_FOLDER= +# Ventana de verificación tras enviar (ms) antes de dar la base por "en tránsito". Default 180000. +# PANEL_DEDUP_MOVE_VERIFY_TIMEOUT_MS=180000 +# Intervalo de sondeo de la verificación (ms). Default 5000. +# PANEL_DEDUP_MOVE_POLL_MS=5000 +# requestTimeout de SQL Server para BACKUP/DROP (el default de node-mssql, 15 s, no alcanza para +# bases reales). Default 3600000 (1 h). +# PANEL_DEDUP_DDL_TIMEOUT_MS=3600000 # PostgreSQL - Usuarios del panel + catálogo ControlDesk (tablas a24c.* las crea otra app) DB_POSTGRES_HOST=localhost @@ -39,8 +51,14 @@ SMTP_USE_TLS=true # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" CLOUDRESTORE_API_TOKEN= -# Clave AES-256 (32 bytes) para cifrar credenciales SQL/SSH de restore_targets en reposo. -# Solo la usa el panel; CloudRestoreAS no necesita esta variable. -# Generar con: +# Clave compartida con a24c para cifrar la contraseña SQL de los nodos en reposo. +# El panel cifra en Fernet (AES-128-CBC + HMAC) derivando la clave como sha256(SECRET_KEY), +# EXACTAMENTE igual que a24c, para que a24c pueda descifrar database_nodes.sql_password. +# ⚠️ DEBE ser idéntica a la SECRET_KEY del backend de a24c, o a24c no podrá conectar. +SECRET_KEY= + +# [LEGADO] Clave AES-256 (32 bytes) del formato antiguo `gcm:` del panel. Solo se usa para +# LEER credenciales cifradas antes de migrar a Fernet; las nuevas se escriben con SECRET_KEY. +# Puede quedar vacía en instalaciones nuevas. Generar (si aplica) con: # node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" ENCRYPTION_KEY= diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 52e79e6..369cb8f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -21,6 +21,12 @@ services: # JWT - JWT_SECRET=${JWT_SECRET} + # Clave Fernet compartida con a24c para cifrar la contraseña SQL de los nodos. + # DEBE ser idéntica a la SECRET_KEY del backend de a24c. + - SECRET_KEY=${SECRET_KEY} + # [LEGADO] Solo si existen credenciales en el formato antiguo `gcm:` por migrar. + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} + # Ruta de respaldos montada desde el host - BACKUP_PATH=/data/backups diff --git a/docker-compose.yml b/docker-compose.yml index fe1af63..0ed1ca0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,8 @@ services: # Integración CloudRestoreAS - CLOUDRESTORE_API_TOKEN=${CLOUDRESTORE_API_TOKEN} + # Clave Fernet compartida con a24c (debe coincidir con la SECRET_KEY de a24c) + - SECRET_KEY=${SECRET_KEY} - ENCRYPTION_KEY=${ENCRYPTION_KEY} volumes: # Map the actual backup folder from host to the container's backup path diff --git a/package-lock.json b/package-lock.json index 0c4f975..6e99a0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "transmitiras-dashboard", "version": "0.0.1", "dependencies": { + "archiver": "^5.3.2", "bcrypt": "^6.0.0", "bootstrap": "^5.3.3", "dotenv": "^16.4.5", @@ -24,6 +25,7 @@ "@sveltejs/adapter-node": "^5.5.2", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0", + "@types/archiver": "^5.3.4", "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/mssql": "^9.1.5", @@ -1498,6 +1500,16 @@ "integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==", "license": "MIT" }, + "node_modules/@types/archiver": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.4.tgz", + "integrity": "sha512-Lj7fLBIMwYFgViVVZHEdExZC3lVYsl+QL0VmdNdIzGZH544jHveYWij6qdnBgJQDnR7pMKliN9z2cPZFEbhyPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readdir-glob": "*" + } + }, "node_modules/@types/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", @@ -1599,6 +1611,16 @@ "@types/node": "*" } }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", diff --git a/package.json b/package.json index d1eb68e..196266a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@sveltejs/adapter-node": "^5.5.2", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0", + "@types/archiver": "^5.3.4", "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/mssql": "^9.1.5", @@ -34,6 +35,7 @@ "vitest": "^2.1.9" }, "dependencies": { + "archiver": "^5.3.2", "bcrypt": "^6.0.0", "bootstrap": "^5.3.3", "dotenv": "^16.4.5", diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 38f4299..1897deb 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -121,12 +121,15 @@ }); type NavItem = { href: string; label: string; icon: string }; - const navGeneral: NavItem[] = [ - { href: '/reportes', label: 'Reportes', icon: 'assessment' } - ]; + // Reportes pasó a Administración: la página /reportes es solo admin (validado también en el + // servidor). navGeneral queda vacío por ahora, pero se conserva por si vuelve a haber ítems + // visibles para todos los roles. + const navGeneral: NavItem[] = []; const navAdmin: NavItem[] = [ + { href: '/reportes', label: 'Reportes', icon: 'assessment' }, { 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: '/servidores-restauracion/depuracion', label: 'Depurar Duplicadas', icon: 'cleaning_services' } ]; function isActive(href: string): boolean { diff --git a/src/lib/nav.test.ts b/src/lib/nav.test.ts index 84911c4..0186e2b 100644 --- a/src/lib/nav.test.ts +++ b/src/lib/nav.test.ts @@ -30,7 +30,7 @@ describe('panelViewsFor', () => { const views = panelViewsFor(false); expect(views.every((view) => !view.adminOnly)).toBe(true); const excluded = PANEL_VIEWS.filter((view) => view.adminOnly).map((view) => view.key); - expect(excluded).toEqual(['clients', 'databases']); + expect(excluded).toEqual(['restored', 'failed', 'clients', 'databases']); expect(views.map((view) => view.key)).toEqual( PANEL_VIEWS.filter((view) => !view.adminOnly).map((view) => view.key) ); diff --git a/src/lib/nav.ts b/src/lib/nav.ts index fcb4d6f..1e2baef 100644 --- a/src/lib/nav.ts +++ b/src/lib/nav.ts @@ -12,8 +12,8 @@ export type PanelView = { const PANEL_VIEW_DEFS = [ { key: 'dashboard', label: 'Resumen', icon: 'insights' }, { key: 'backups', label: 'Respaldos Almacenados', icon: 'inventory_2' }, - { key: 'restored', label: 'Respaldos Restaurados', icon: 'cloud_done' }, - { key: 'failed', label: 'Restores Fallidos', icon: 'error_outline' }, + { key: 'restored', label: 'Respaldos Restaurados', icon: 'cloud_done', adminOnly: true }, + { key: 'failed', label: 'Restores Fallidos', icon: 'error_outline', adminOnly: true }, { key: 'clients', label: 'Catálogo de Clientes', icon: 'group', adminOnly: true }, { key: 'alerts', label: 'Alertas Críticas', icon: 'warning_amber' }, { key: 'databases', label: 'Gestión de Bases de Datos', icon: 'dns', adminOnly: true } diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts index 4b578ea..bc59c87 100644 --- a/src/lib/server/controldesk-pg.ts +++ b/src/lib/server/controldesk-pg.ts @@ -612,6 +612,46 @@ export async function getRestoreTargetById(id: number): Promise { + const r = await pgPool.query( + `SELECT ${ROW_RESTORE_TARGET}, sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $1`, + [id] + ); + const row = r.rows[0]; + if (!row) return null; + const { sql_password_encrypted, ...rest } = row; + const sql_password = sql_password_encrypted ? decryptSecret(sql_password_encrypted) : ''; + return { ...(rest as RestoreTarget), sql_password }; +} + +/** + * Servidor de restauración por id con AMBAS contraseñas descifradas (SQL para BACKUP/RESTORE, SSH + * para SFTP). Uso exclusivo del servidor (mover bases duplicadas); NUNCA se expone al cliente. + */ +export async function getRestoreTargetWithSecretsById( + id: number +): Promise<(RestoreTarget & { sql_password: string; ssh_password: string }) | null> { + const r = await pgPool.query( + `SELECT ${ROW_RESTORE_TARGET}, sql_password_encrypted, ssh_password_encrypted + FROM ${qRestoreTargets()} WHERE id = $1`, + [id] + ); + const row = r.rows[0]; + if (!row) return null; + const { sql_password_encrypted, ssh_password_encrypted, ...rest } = row; + return { + ...(rest as RestoreTarget), + sql_password: sql_password_encrypted ? decryptSecret(sql_password_encrypted) : '', + ssh_password: ssh_password_encrypted ? decryptSecret(ssh_password_encrypted) : '' + }; +} + /** * 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 diff --git a/src/lib/server/crypto.ts b/src/lib/server/crypto.ts index fe1e755..d451944 100644 --- a/src/lib/server/crypto.ts +++ b/src/lib/server/crypto.ts @@ -1,26 +1,39 @@ /** - * Cifrado simétrico AES-256-GCM para secretos en reposo (estándar Aduanasoft §4). - * Se usa para `restore_targets.sql_password`: las credenciales SQL de los servidores - * de restauración nunca se guardan en texto plano en PostgreSQL. + * Cifrado simétrico de secretos en reposo para `restore_targets.sql_password` y su copia + * en `database_nodes.sql_password`. * - * La lógica criptográfica vive en `crypto-core.ts` (pura y testeable). Aquí solo se - * resuelve la clave de 256 bits desde ENCRYPTION_KEY y se delega. + * IMPORTANTE — interoperabilidad con a24c: + * a24c (Python/FastAPI) lee `database_nodes.sql_password` y lo descifra con + * `cryptography.fernet.Fernet`, usando la clave `sha256(SECRET_KEY)`. Por eso el panel + * cifra en **Fernet** con la MISMA `SECRET_KEY`, para que ambos proyectos se entiendan. + * + * - Escritura: siempre Fernet (`fernet.ts`), clave derivada de `SECRET_KEY`. + * - Lectura: Fernet y, por compatibilidad, el formato legado AES-256-GCM (`gcm:...`, + * `crypto-core.ts`) que el panel usaba antes con `ENCRYPTION_KEY`. */ import { env } from '$env/dynamic/private'; -import { encryptWithKey, decryptWithKey, decodeKey, isEncrypted } from './crypto-core'; +import { decryptWithKey, decodeKey, isEncrypted as isGcmEnvelope } from './crypto-core'; +import { deriveFernetKey, fernetEncrypt, fernetDecrypt, isFernetToken } from './fernet'; -function getKey(): Buffer { - return decodeKey(env.ENCRYPTION_KEY); +function getFernetKey(): Buffer { + return deriveFernetKey(env.SECRET_KEY ?? ''); } -/** Cifra un secreto en texto plano. Devuelve el sobre versionado listo para persistir. */ +/** Cifra un secreto en texto plano como token Fernet, legible por a24c. */ export function encryptSecret(plaintext: string): string { - return encryptWithKey(plaintext, getKey()); + return fernetEncrypt(plaintext, getFernetKey(), Date.now() / 1000); } -/** Descifra un sobre producido por `encryptSecret`. */ +/** Descifra un secreto: Fernet (actual) o AES-256-GCM `gcm:` (legado del panel). */ export function decryptSecret(payload: string): string { - return decryptWithKey(payload, getKey()); + if (isGcmEnvelope(payload)) { + // Datos históricos cifrados por el panel antes de migrar a Fernet. + return decryptWithKey(payload, decodeKey(env.ENCRYPTION_KEY)); + } + return fernetDecrypt(payload, getFernetKey()); } -export { isEncrypted }; +/** Indica si un valor ya está cifrado (Fernet o el formato legado `gcm:`). */ +export function isEncrypted(value: string | null | undefined): boolean { + return isGcmEnvelope(value) || isFernetToken(value); +} diff --git a/src/lib/server/db-move.ts b/src/lib/server/db-move.ts new file mode 100644 index 0000000..7e58a05 --- /dev/null +++ b/src/lib/server/db-move.ts @@ -0,0 +1,190 @@ +/** + * "Mandar al nuevo": mueve una base que quedó solo en el servidor viejo hacia su servidor nuevo + * REUSANDO CloudRestoreAS. Flujo: BACKUP en el viejo -> SFTP baja el .bak -> se comprime a .zip + * -> SFTP sube el .zip a la Entrada del nuevo (CRA lo restaura) -> verificación acotada -> si ya + * quedó bien en el nuevo, se borra del viejo (borrado automático al confirmar). + * + * Si el restore de CRA tarda más que la ventana de verificación, la base queda 'en_transito': el + * siguiente escaneo la mostrará 🟢 y el borrado se completa con el botón de borrado existente. + */ +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { env } from '$env/dynamic/private'; +import { + getMssqlPoolMaster, + resolveNodeSqlPassword, + queryDatabaseMetricsOnServer, + listUserDatabasesOnServer, + backupDatabaseOnServer, + dropDatabaseOnServer +} from './mssql-nodes'; +import { + getRestoreTargetWithSecretsById, + listDatabaseNodesForMssql +} from './controldesk-pg'; +import { ddlTimeoutMs, indexNodesByDbName, normalizeServerHost, sizeTolerance } from './dedup-databases'; +import { + joinRemotePath, + sftpDownload, + sftpUploadAtomic, + sftpDelete, + zipSingleFile, + type SftpCreds +} from './sftp-transfer'; +import { logger } from './logger'; + +function verifyTimeoutMs(): number { + const v = Number(env.PANEL_DEDUP_MOVE_VERIFY_TIMEOUT_MS); + return Number.isFinite(v) && v > 0 ? v : 180000; // 3 min por defecto +} +function verifyPollMs(): number { + const v = Number(env.PANEL_DEDUP_MOVE_POLL_MS); + return Number.isFinite(v) && v >= 1000 ? v : 5000; +} +/** Carpeta donde el SQL viejo escribe el .bak (default: data_folder del restore_target viejo). */ +function backupFolderFor(dataFolder: string): string { + const override = String(env.PANEL_DEDUP_BACKUP_FOLDER || '').trim(); + return override || dataFolder; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Nombre base del archivo (stem) que CRA usa para mapear el respaldo a su nodo. */ +export function backupStemForNode(node: any, fallbackDbName: string): string { + return String(node?.NodoSubNodo ?? '').trim() || String(fallbackDbName ?? '').trim(); +} + +function credsOf(t: { ssh_host: string; ssh_port: number; ssh_username: string; ssh_password: string }): SftpCreds { + return { host: t.ssh_host, port: t.ssh_port, username: t.ssh_username, password: t.ssh_password }; +} + +export type MoveStatus = 'movida_y_borrada' | 'en_transito' | 'ya_en_nuevo'; +export type MoveResult = { name: string; status: MoveStatus; message: string }; + +/** + * Mueve `dbName` del servidor viejo (`oldTargetId`) a su servidor nuevo asignado en el catálogo. + * Lanza si falta algún requisito (nodo, destino, credenciales) o si el backup/transferencia fallan. + */ +export async function moveDatabaseToNewServer( + oldTargetId: number, + dbName: string, + actor?: string +): Promise { + const oldTarget = await getRestoreTargetWithSecretsById(oldTargetId); + if (!oldTarget) throw new Error('Servidor viejo no encontrado.'); + if (!oldTarget.ssh_host || !oldTarget.ssh_username) { + throw new Error('El servidor viejo no tiene credenciales SSH configuradas.'); + } + + const oldPool = await getMssqlPoolMaster(oldTarget.server_ip, oldTarget.sql_password, oldTarget.sql_username); + // Pool aparte con timeout amplio para las operaciones largas (BACKUP y DROP) sobre el viejo. + const oldPoolDDL = await getMssqlPoolMaster( + oldTarget.server_ip, + oldTarget.sql_password, + oldTarget.sql_username, + ddlTimeoutMs() + ); + const oldDbs = await listUserDatabasesOnServer(oldPool); + const oldInfo = oldDbs.find((d) => d.name.toLowerCase() === dbName.trim().toLowerCase()); + if (!oldInfo) throw new Error(`La base "${dbName}" no existe en el servidor viejo.`); + const realName = oldInfo.name; + const allowedNames = new Set(oldDbs.map((d) => d.name)); + + const node = indexNodesByDbName(await listDatabaseNodesForMssql()).get(realName.toLowerCase()); + if (!node) throw new Error(`La base "${realName}" no tiene un nodo activo en el catálogo; no se puede enrutar.`); + + const newTargetId = Number(node.RestoreTargetId); + if (!Number.isInteger(newTargetId) || newTargetId <= 0) { + throw new Error('El nodo no tiene servidor de restauración asignado.'); + } + const newTarget = await getRestoreTargetWithSecretsById(newTargetId); + if (!newTarget) throw new Error('Servidor nuevo (destino) no encontrado.'); + if (!newTarget.ssh_host || !newTarget.ssh_username) { + throw new Error('El servidor nuevo no tiene credenciales SSH configuradas.'); + } + if (!newTarget.remote_inbox_path) { + throw new Error('El servidor nuevo no tiene carpeta de Entrada (remote_inbox_path) configurada.'); + } + if (normalizeServerHost(oldTarget.server_ip) === normalizeServerHost(newTarget.server_ip)) { + throw new Error('El destino nuevo es el mismo servidor viejo; no hay nada que mover.'); + } + + const tolerance = sizeTolerance(); + const newServer = String(node.ServerName || '').trim(); + const newPool = await getMssqlPoolMaster(newServer, resolveNodeSqlPassword(node.sql_password)); + + // Si ya existe en el nuevo, no re-enviamos (evita trabajo y sobrescrituras). + const already = await queryDatabaseMetricsOnServer(newPool, realName); + if (already) { + return { name: realName, status: 'ya_en_nuevo', message: 'La base ya existe en el servidor nuevo.' }; + } + + const stem = backupStemForNode(node, realName); + const bakName = `${stem}.bak`; + const zipName = `${stem}.zip`; + const bakRemoteOld = joinRemotePath(backupFolderFor(oldTarget.data_folder), bakName); + const zipRemoteNew = joinRemotePath(newTarget.remote_inbox_path, zipName); + + const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dedup-move-')); + const localBak = path.join(workDir, bakName); + const localZip = path.join(workDir, zipName); + + try { + logger.info({ + message: 'dedup-move: iniciando', + context: { db: realName, from: oldTarget.server_ip, to: newTarget.server_ip, actor: actor ?? null } + }); + + await backupDatabaseOnServer(oldPoolDDL, realName, allowedNames, bakRemoteOld); + await sftpDownload(credsOf(oldTarget), bakRemoteOld, localBak); + await zipSingleFile(localBak, bakName, localZip); + await sftpUploadAtomic(credsOf(newTarget), localZip, zipRemoteNew); + + // Limpieza del .bak temporal en el viejo (best-effort, no aborta el flujo). + try { + await sftpDelete(credsOf(oldTarget), bakRemoteOld); + } catch (e) { + logger.warn({ + message: 'dedup-move: no se pudo borrar el .bak temporal del viejo', + context: { db: realName, path: bakRemoteOld, error: e instanceof Error ? e.message : String(e) } + }); + } + } finally { + await fs.rm(workDir, { recursive: true, force: true }).catch(() => {}); + } + + // Verificación acotada: esperar a que CRA restaure en el nuevo con tamaño coherente. + const deadline = Date.now() + verifyTimeoutMs(); + const threshold = oldInfo.size_mb * (1 - tolerance); + let confirmed = false; + while (Date.now() < deadline) { + await sleep(verifyPollMs()); + const info = await queryDatabaseMetricsOnServer(newPool, realName); + if (info && (Number(info.size_mb) || 0) >= threshold) { + confirmed = true; + break; + } + } + + if (!confirmed) { + logger.info({ + message: 'dedup-move: en tránsito (CRA aún no confirma)', + context: { db: realName, to: newTarget.server_ip } + }); + return { + name: realName, + status: 'en_transito', + message: 'Enviada al servidor nuevo. CloudRestoreAS la está restaurando; se borrará del viejo al confirmar (re-escanea).' + }; + } + + await dropDatabaseOnServer(oldPoolDDL, realName, allowedNames); + logger.info({ + message: 'dedup-move: movida y borrada del viejo', + context: { db: realName, from: oldTarget.server_ip, to: newTarget.server_ip, actor: actor ?? null } + }); + return { name: realName, status: 'movida_y_borrada', message: 'Movida al servidor nuevo y borrada del viejo.' }; +} diff --git a/src/lib/server/dedup-databases.test.ts b/src/lib/server/dedup-databases.test.ts new file mode 100644 index 0000000..7bd9658 --- /dev/null +++ b/src/lib/server/dedup-databases.test.ts @@ -0,0 +1,151 @@ +/** + * Pruebas de la lógica de depuración de bases duplicadas. Cubre la decisión pura de borrado + * (classifyDuplicate / isDeletable), la normalización de host para detectar "mismo servidor" y + * la validación por whitelist de dropDatabaseOnServer (defensa contra inyección/borrado indebido). + */ +import { describe, it, expect, vi } from 'vitest'; +import { + classifyDuplicate, + isDeletable, + normalizeServerHost, + resolveCatalogState, + DEFAULT_SIZE_TOLERANCE, + type DuplicateInput +} from './dedup-databases'; +import { backupStemForNode } from './db-move'; +import { dropDatabaseOnServer } from './mssql-nodes'; + +const base: DuplicateInput = { + catalogState: 'active', + sameServer: false, + newVerified: true, + newExists: true, + oldSizeMb: 1000, + newSizeMb: 1000 +}; + +describe('classifyDuplicate', () => { + const tol = DEFAULT_SIZE_TOLERANCE; // 0.2 -> el nuevo debe pesar >= 800 MB + + it('sin nodo en el catálogo no se sabe el destino -> no_catalogo', () => { + expect(classifyDuplicate({ ...base, catalogState: 'absent' }, tol)).toBe('no_catalogo'); + }); + + it('el nodo existe pero está desactivado -> nodo_desactivado', () => { + expect(classifyDuplicate({ ...base, catalogState: 'inactive' }, tol)).toBe('nodo_desactivado'); + }); + + it('nodo desactivado tiene precedencia aunque el nuevo no cuadre', () => { + expect( + classifyDuplicate({ ...base, catalogState: 'inactive', newExists: false, sameServer: true }, tol) + ).toBe('nodo_desactivado'); + }); + + it('el destino nuevo es el mismo servidor -> mismo_servidor (nunca borrable)', () => { + expect(classifyDuplicate({ ...base, sameServer: true }, tol)).toBe('mismo_servidor'); + }); + + it('no se pudo verificar el servidor nuevo -> error_nuevo', () => { + expect(classifyDuplicate({ ...base, newVerified: false }, tol)).toBe('error_nuevo'); + }); + + it('verificado pero la base no existe en el nuevo -> falta_en_nuevo', () => { + expect(classifyDuplicate({ ...base, newExists: false }, tol)).toBe('falta_en_nuevo'); + }); + + it('existe en el nuevo con tamaño coherente -> segura', () => { + expect(classifyDuplicate({ ...base, oldSizeMb: 1000, newSizeMb: 900 }, tol)).toBe('segura'); + }); + + it('el tamaño en el límite (>= 80%) sigue siendo segura', () => { + expect(classifyDuplicate({ ...base, oldSizeMb: 1000, newSizeMb: 800 }, tol)).toBe('segura'); + }); + + it('el nuevo pesa mucho menos que el viejo -> revisar (posible copia incompleta)', () => { + expect(classifyDuplicate({ ...base, oldSizeMb: 1000, newSizeMb: 500 }, tol)).toBe('revisar'); + }); + + it('base vacía en el viejo (0 MB): cualquier tamaño en el nuevo es coherente -> segura', () => { + expect(classifyDuplicate({ ...base, oldSizeMb: 0, newSizeMb: 0 }, tol)).toBe('segura'); + }); +}); + +describe('isDeletable', () => { + it('solo "segura" es borrable', () => { + expect(isDeletable('segura')).toBe(true); + for (const s of [ + 'revisar', + 'falta_en_nuevo', + 'nodo_desactivado', + 'no_catalogo', + 'mismo_servidor', + 'error_nuevo' + ] as const) { + expect(isDeletable(s)).toBe(false); + } + }); +}); + +describe('resolveCatalogState', () => { + const active = new Map([['ventasdb', { BDName: 'VentasDB' }]]); + const all = new Map([ + ['ventasdb', { BDName: 'VentasDB' }], + ['viejadb', { BDName: 'ViejaDB' }] // en el catálogo pero NO en activos -> desactivado + ]); + + it('activo si está en el índice de nodos activos', () => { + expect(resolveCatalogState('VentasDB', active, all)).toBe('active'); + }); + it('desactivado si está en el catálogo pero no entre los activos', () => { + expect(resolveCatalogState('ViejaDB', active, all)).toBe('inactive'); + }); + it('ausente si no está en el catálogo', () => { + expect(resolveCatalogState('OtraDB', active, all)).toBe('absent'); + }); +}); + +describe('backupStemForNode', () => { + it('prefiere node_subnode_key (NodoSubNodo) para que CRA enrute el respaldo', () => { + expect(backupStemForNode({ NodoSubNodo: 'NODO001', BDName: 'VentasDB' }, 'VentasDB')).toBe('NODO001'); + }); + it('cae al nombre de la base si no hay NodoSubNodo', () => { + expect(backupStemForNode({ NodoSubNodo: ' ' }, 'VentasDB')).toBe('VentasDB'); + }); +}); + +describe('normalizeServerHost', () => { + it('ignora mayúsculas y espacios, e iguala host,puerto equivalentes', () => { + expect(normalizeServerHost(' HOST01,1433 ')).toBe(normalizeServerHost('host01,1433')); + }); + + it('distingue host distinto y puerto distinto', () => { + expect(normalizeServerHost('host01,1433')).not.toBe(normalizeServerHost('host02,1433')); + expect(normalizeServerHost('host01,1433')).not.toBe(normalizeServerHost('host01,1434')); + }); +}); + +describe('dropDatabaseOnServer (guard de whitelist)', () => { + const allowed = new Set(['VentasDB', 'ComprasDB']); + + it('rechaza un nombre fuera de la whitelist ANTES de tocar el pool', async () => { + const pool = { request: vi.fn() } as any; + await expect(dropDatabaseOnServer(pool, 'master', allowed)).rejects.toThrow(/no permitida/i); + await expect(dropDatabaseOnServer(pool, '', allowed)).rejects.toThrow(/no permitida/i); + // Intento de inyección: el string completo no está en la whitelist, así que ni llega al pool. + await expect( + dropDatabaseOnServer(pool, 'VentasDB]; DROP DATABASE Otra;--', allowed) + ).rejects.toThrow(/no permitida/i); + expect(pool.request).not.toHaveBeenCalled(); + }); + + it('un nombre permitido llega al pool con @dbname parametrizado', async () => { + const query = vi.fn().mockResolvedValue({}); + const input = vi.fn(); + const request = vi.fn(() => ({ input, query })); + const pool = { request } as any; + await dropDatabaseOnServer(pool, 'VentasDB', allowed); + expect(input).toHaveBeenCalledWith('dbname', expect.anything(), 'VentasDB'); + expect(query).toHaveBeenCalledTimes(1); + expect(String(query.mock.calls[0][0])).toContain('QUOTENAME'); + }); +}); diff --git a/src/lib/server/dedup-databases.ts b/src/lib/server/dedup-databases.ts new file mode 100644 index 0000000..10e3927 --- /dev/null +++ b/src/lib/server/dedup-databases.ts @@ -0,0 +1,320 @@ +/** + * Depuración de bases duplicadas: al mover bases a un servidor nuevo, las copias quedaron + * también en el viejo. Aquí se reconcilia (¿la base ya está bien en el nuevo?) y se borran + * las copias del servidor viejo de forma segura. + * + * El servidor viejo se identifica por su restore_target; el nuevo es el `server_name` que el + * catálogo (database_nodes) tiene asignado a esa base. Criterio de "segura para borrar": + * existe en el nuevo con tamaño coherente (>= (1 - tolerancia) del tamaño en el viejo). + */ +import { env } from '$env/dynamic/private'; +import { + getMssqlPoolMaster, + resolveNodeSqlPassword, + queryDatabaseMetricsOnServer, + listUserDatabasesOnServer, + dropDatabaseOnServer, + parseMssqlServer, + mapWithConcurrency +} from './mssql-nodes'; +import { + getRestoreTargetWithPasswordById, + listDatabaseNodes, + listDatabaseNodesForMssql +} from './controldesk-pg'; +import { logger } from './logger'; + +export const DEFAULT_SIZE_TOLERANCE = 0.2; + +/** Tolerancia de tamaño (fracción 0–1). El nuevo debe pesar >= (1 - tolerancia) del viejo. */ +export function sizeTolerance(): number { + const v = Number(env.PANEL_DEDUP_SIZE_TOLERANCE); + return Number.isFinite(v) && v >= 0 && v < 1 ? v : DEFAULT_SIZE_TOLERANCE; +} + +/** + * requestTimeout para operaciones largas de SQL Server (BACKUP/DROP). El default de node-mssql + * (15 s) aborta un BACKUP/DROP real. Default 1 h, configurable. + */ +export function ddlTimeoutMs(): number { + const v = Number(env.PANEL_DEDUP_DDL_TIMEOUT_MS); + return Number.isFinite(v) && v > 0 ? v : 3600000; +} + +/** Estado de la base en el catálogo del panel (database_nodes). */ +export type CatalogState = 'active' | 'inactive' | 'absent'; + +export type DuplicateStatus = + | 'segura' // existe en el nuevo con tamaño coherente -> se puede borrar del viejo + | 'revisar' // existe en el nuevo pero el tamaño no cuadra + | 'falta_en_nuevo' // no existe en el servidor nuevo (candidata a "mandar al nuevo") + | 'nodo_desactivado' // la base está en el catálogo pero su nodo está desactivado + | 'no_catalogo' // la base no está en el catálogo (no se sabe su destino) + | 'mismo_servidor' // el destino nuevo ES este mismo servidor (evita borrar la copia viva) + | 'error_nuevo'; // no se pudo verificar el servidor nuevo (conexión/sin destino) + +export type DuplicateInput = { + catalogState: CatalogState; + sameServer: boolean; + newVerified: boolean; + newExists: boolean; + oldSizeMb: number; + newSizeMb: number; +}; + +/** + * Clasifica una base del servidor viejo. Pura y sin efectos: es el único punto que decide si una + * base es borrable, tanto en el escaneo como en la re-verificación previa al borrado. + */ +export function classifyDuplicate(inp: DuplicateInput, tolerance: number): DuplicateStatus { + if (inp.catalogState === 'absent') return 'no_catalogo'; + if (inp.catalogState === 'inactive') return 'nodo_desactivado'; + if (inp.sameServer) return 'mismo_servidor'; + if (!inp.newVerified) return 'error_nuevo'; + if (!inp.newExists) return 'falta_en_nuevo'; + const threshold = inp.oldSizeMb * (1 - tolerance); + return inp.newSizeMb >= threshold ? 'segura' : 'revisar'; +} + +export function isDeletable(status: DuplicateStatus): boolean { + return status === 'segura'; +} + +/** Normaliza `host,puerto` / `host\instancia` a una clave comparable para detectar mismo servidor. */ +export function normalizeServerHost(address: string): string { + const { server, port } = parseMssqlServer(String(address ?? '').trim()); + return `${server.toLowerCase()}|${port ?? ''}`; +} + +export type DuplicateRow = { + name: string; + oldSizeMb: number; + oldLastRestore: string | null; + newServer: string | null; + newServerLabel: string | null; + newSizeMb: number | null; + newLastRestore: string | null; + status: DuplicateStatus; + deletable: boolean; + /** true si la base es candidata a mandarse al servidor nuevo (falta_en_nuevo con nodo activo). */ + movable: boolean; +}; + +export type ScanResult = { + target: { id: number; name: string; server_ip: string }; + rows: DuplicateRow[]; +}; + +function toIso(value: unknown): string | null { + if (!value) return null; + const d = value instanceof Date ? value : new Date(value as string); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +} + +/** Indexa nodos del catálogo por nombre de base (minúsculas); conserva el primero. */ +export function indexNodesByDbName(nodes: any[]): Map { + const byName = new Map(); + for (const n of nodes) { + const key = String(n.BDName ?? '').trim().toLowerCase(); + if (key && !byName.has(key)) byName.set(key, n); + } + return byName; +} + +/** Estado en el catálogo: activo (con destino conectable), desactivado, o ausente. */ +export function resolveCatalogState( + dbName: string, + activeByName: Map, + allByName: Map +): CatalogState { + const key = dbName.toLowerCase(); + if (activeByName.has(key)) return 'active'; + if (allByName.has(key)) return 'inactive'; + return 'absent'; +} + +function nodeLabel(node: any): string | null { + return String(node?.Nombre ?? node?.NodoSubNodo ?? '').trim() || null; +} + +export type NewServerCheck = { + sameServer: boolean; + newVerified: boolean; + newExists: boolean; + newSizeMb: number | null; + newLastRestore: string | null; + newServer: string | null; +}; + +const EMPTY_NEW_CHECK: NewServerCheck = { + sameServer: false, + newVerified: false, + newExists: false, + newSizeMb: null, + newLastRestore: null, + newServer: null +}; + +/** Verifica la copia en el servidor nuevo asignado a `node` para una base dada. */ +export async function verifyOnNewServer( + node: any, + dbName: string, + oldHost: string +): Promise { + const newServer = String(node?.ServerName ?? '').trim(); + const sameServer = !!newServer && normalizeServerHost(newServer) === oldHost; + if (sameServer || !newServer) { + return { ...EMPTY_NEW_CHECK, sameServer, newServer: newServer || null }; + } + try { + const pool = await getMssqlPoolMaster(newServer, resolveNodeSqlPassword(node.sql_password)); + const info = await queryDatabaseMetricsOnServer(pool, dbName); + return { + sameServer: false, + newVerified: true, + newExists: !!info, + newSizeMb: info ? Number(info.size_mb) || 0 : null, + newLastRestore: info ? toIso(info.last_restore_date) : null, + newServer + }; + } catch (e) { + logger.error({ + message: 'dedup: no se pudo verificar el servidor nuevo', + context: { db: dbName, server: newServer, error: e instanceof Error ? e.message : String(e) } + }); + return { ...EMPTY_NEW_CHECK, newServer }; + } +} + +/** + * Escanea el servidor viejo (restore_target) y reconcilia cada base de usuario contra su destino + * nuevo en el catálogo. No borra nada. + */ +export async function scanDuplicates(oldTargetId: number): Promise { + const target = await getRestoreTargetWithPasswordById(oldTargetId); + if (!target) throw new Error('Servidor de restauración no encontrado.'); + + const tolerance = sizeTolerance(); + const oldHost = normalizeServerHost(target.server_ip); + const oldPool = await getMssqlPoolMaster(target.server_ip, target.sql_password, target.sql_username); + const oldDbs = await listUserDatabasesOnServer(oldPool); + // Nodos activos (con sql_password, para conectar al nuevo) + TODOS los nodos (para distinguir + // los que están en el catálogo pero desactivados de los que no están en absoluto). + const activeByName = indexNodesByDbName(await listDatabaseNodesForMssql()); + const allByName = indexNodesByDbName(await listDatabaseNodes()); + + const rows = await mapWithConcurrency(oldDbs, 8, async (oldDb): Promise => { + const key = oldDb.name.toLowerCase(); + const catalogState = resolveCatalogState(oldDb.name, activeByName, allByName); + const activeNode = activeByName.get(key); + const anyNode = allByName.get(key); + const v = + catalogState === 'active' && activeNode + ? await verifyOnNewServer(activeNode, oldDb.name, oldHost) + : EMPTY_NEW_CHECK; + const status = classifyDuplicate( + { + catalogState, + sameServer: v.sameServer, + newVerified: v.newVerified, + newExists: v.newExists, + oldSizeMb: oldDb.size_mb, + newSizeMb: v.newSizeMb ?? 0 + }, + tolerance + ); + return { + name: oldDb.name, + oldSizeMb: oldDb.size_mb, + oldLastRestore: toIso(oldDb.last_restore_date), + newServer: v.newServer ?? (anyNode ? String(anyNode.ServerName ?? '').trim() || null : null), + newServerLabel: nodeLabel(activeNode ?? anyNode), + newSizeMb: v.newSizeMb, + newLastRestore: v.newLastRestore, + status, + deletable: isDeletable(status), + movable: status === 'falta_en_nuevo' + }; + }); + + return { target: { id: target.id, name: target.name, server_ip: target.server_ip }, rows }; +} + +export type DropOutcome = { name: string; ok: boolean; status: string; message?: string }; + +/** + * Borra del servidor viejo las bases indicadas, RE-VERIFICANDO la seguridad del lado servidor + * (no confía en el cliente): solo borra las que siguen clasificando como 'segura'. + */ +export async function dropDuplicates( + oldTargetId: number, + names: string[], + actor?: string +): Promise { + const target = await getRestoreTargetWithPasswordById(oldTargetId); + if (!target) throw new Error('Servidor de restauración no encontrado.'); + + const tolerance = sizeTolerance(); + const oldHost = normalizeServerHost(target.server_ip); + const oldPool = await getMssqlPoolMaster(target.server_ip, target.sql_password, target.sql_username); + // Pool con timeout amplio para el DROP (SINGLE_USER + ROLLBACK puede pasar de 15 s). + const oldPoolDDL = await getMssqlPoolMaster( + target.server_ip, + target.sql_password, + target.sql_username, + ddlTimeoutMs() + ); + const oldDbs = await listUserDatabasesOnServer(oldPool); + const oldByName = new Map(oldDbs.map((d) => [d.name.toLowerCase(), d])); + const allowedNames = new Set(oldDbs.map((d) => d.name)); // whitelist exacta del propio servidor + const activeByName = indexNodesByDbName(await listDatabaseNodesForMssql()); + const allByName = indexNodesByDbName(await listDatabaseNodes()); + + const outcomes: DropOutcome[] = []; + for (const rawName of names) { + const name = String(rawName ?? '').trim(); + const oldInfo = oldByName.get(name.toLowerCase()); + if (!oldInfo) { + outcomes.push({ name, ok: false, status: 'no_existe', message: 'La base ya no existe en el servidor viejo.' }); + continue; + } + const realName = oldInfo.name; // casing canónico del servidor + const catalogState = resolveCatalogState(realName, activeByName, allByName); + const activeNode = activeByName.get(realName.toLowerCase()); + const v = + catalogState === 'active' && activeNode + ? await verifyOnNewServer(activeNode, realName, oldHost) + : EMPTY_NEW_CHECK; + const status = classifyDuplicate( + { + catalogState, + sameServer: v.sameServer, + newVerified: v.newVerified, + newExists: v.newExists, + oldSizeMb: oldInfo.size_mb, + newSizeMb: v.newSizeMb ?? 0 + }, + tolerance + ); + if (!isDeletable(status)) { + outcomes.push({ name: realName, ok: false, status, message: 'No pasó la verificación de seguridad; no se borró.' }); + continue; + } + try { + await dropDatabaseOnServer(oldPoolDDL, realName, allowedNames); + logger.info({ + message: 'dedup: base borrada del servidor viejo', + context: { db: realName, server: target.server_ip, target_id: target.id, actor: actor ?? null } + }); + outcomes.push({ name: realName, ok: true, status: 'borrada' }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + logger.error({ + message: 'dedup: fallo al borrar base del servidor viejo', + context: { db: realName, server: target.server_ip, error: msg } + }); + outcomes.push({ name: realName, ok: false, status: 'error_drop', message: msg }); + } + } + return outcomes; +} diff --git a/src/lib/server/fernet.test.ts b/src/lib/server/fernet.test.ts new file mode 100644 index 0000000..17286ec --- /dev/null +++ b/src/lib/server/fernet.test.ts @@ -0,0 +1,74 @@ +/** + * Pruebas del esquema Fernet, compatible con `cryptography.fernet.Fernet` de a24c. + * Cubre: round-trip, detección de token, HMAC, clave errónea y derivación de clave. + */ +import { describe, it, expect } from 'vitest'; +import { + deriveFernetKey, + fernetEncrypt, + fernetDecrypt, + isFernetToken +} from './fernet'; + +const SECRET = 'clave-secreta-de-prueba-compartida-con-a24c'; +const KEY = deriveFernetKey(SECRET); +const TS = 1_700_000_000; // segundos Unix fijos para determinismo + +describe('fernet (interop a24c)', () => { + it('deriva una clave de 32 bytes desde SECRET_KEY (sha256)', () => { + expect(KEY.length).toBe(32); + // La misma SECRET_KEY produce siempre la misma clave + expect(deriveFernetKey(SECRET).equals(KEY)).toBe(true); + }); + + it('lanza si SECRET_KEY está vacía', () => { + expect(() => deriveFernetKey('')).toThrow(/SECRET_KEY/); + expect(() => deriveFernetKey(' ')).toThrow(/SECRET_KEY/); + }); + + it('round-trip: descifrar devuelve el texto original (incl. UTF-8)', () => { + for (const pt of ['Soluciones01!', 'ClaveConÑ_áé#2024', '']) { + const token = fernetEncrypt(pt, KEY, TS); + expect(fernetDecrypt(token, KEY)).toBe(pt); + } + }); + + it('produce un token Fernet reconocible (prefijo gAAAAA, base64-url)', () => { + const token = fernetEncrypt('secreto', KEY, TS); + expect(token.startsWith('gAAAAA')).toBe(true); + expect(isFernetToken(token)).toBe(true); + }); + + it('isFernetToken rechaza texto plano y valores no-token', () => { + expect(isFernetToken('Soluciones01!')).toBe(false); + expect(isFernetToken('gcm:a:b:c')).toBe(false); + expect(isFernetToken('')).toBe(false); + expect(isFernetToken(null)).toBe(false); + expect(isFernetToken(undefined)).toBe(false); + }); + + it('usa IV aleatorio: dos cifrados difieren pero descifran igual', () => { + const a = fernetEncrypt('mismo', KEY, TS); + const b = fernetEncrypt('mismo', KEY, TS); + expect(a).not.toBe(b); + expect(fernetDecrypt(a, KEY)).toBe(fernetDecrypt(b, KEY)); + }); + + it('falla con clave incorrecta (HMAC no valida)', () => { + const token = fernetEncrypt('secreto', KEY, TS); + const otherKey = deriveFernetKey('otra-secret-key'); + expect(() => fernetDecrypt(token, otherKey)).toThrow(/HMAC/); + }); + + it('detecta manipulación del token', () => { + const token = fernetEncrypt('integridad', KEY, TS); + const data = Buffer.from(token.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + data[20] = data[20] ^ 0xff; // altera un byte del ciphertext + const tampered = data.toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); + expect(() => fernetDecrypt(tampered, KEY)).toThrow(); + }); + + it('rechaza tokens demasiado cortos o con versión inválida', () => { + expect(() => fernetDecrypt('gA==', KEY)).toThrow(/corto/); + }); +}); diff --git a/src/lib/server/fernet.ts b/src/lib/server/fernet.ts new file mode 100644 index 0000000..2b536fc --- /dev/null +++ b/src/lib/server/fernet.ts @@ -0,0 +1,115 @@ +/** + * Implementación pura del esquema Fernet (spec oficial), compatible byte a byte con + * `cryptography.fernet.Fernet` de Python — que es lo que usa a24c para descifrar la + * contraseña SQL de cada nodo (`database_nodes.sql_password`). + * + * Fernet = AES-128-CBC (PKCS7) + HMAC-SHA256, sobre una clave de 32 bytes: + * - bytes [0..16) → clave de firma (HMAC) + * - bytes [16..32) → clave de cifrado (AES-128) + * + * Formato del token (antes de base64-url): + * 0x80 | timestamp(8, big-endian) | iv(16) | ciphertext(múltiplo de 16) | hmac(32) + * + * a24c deriva la clave así: base64.urlsafe_b64encode(sha256(SECRET_KEY).digest()) + * que Fernet vuelve a decodificar a los 32 bytes crudos de `sha256(SECRET_KEY)`. + * Aquí replicamos exactamente esa derivación con `deriveFernetKey`. + */ +import { + createCipheriv, + createDecipheriv, + createHash, + createHmac, + randomBytes, + timingSafeEqual +} from 'node:crypto'; + +const FERNET_VERSION = 0x80; +const KEY_LENGTH = 32; // 16 firma + 16 cifrado +const IV_LENGTH = 16; +const HMAC_LENGTH = 32; +const HEADER_LENGTH = 1 + 8 + IV_LENGTH; // version + timestamp + iv +/** Longitud mínima de un token válido: header + 1 bloque AES + hmac. */ +const MIN_TOKEN_BYTES = HEADER_LENGTH + 16 + HMAC_LENGTH; + +function toUrlSafeBase64(buf: Buffer): string { + // Padded url-safe base64 (con `=`), como produce Python; su decoder lo exige. + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); +} + +function fromUrlSafeBase64(token: string): Buffer { + return Buffer.from(token.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); +} + +/** Deriva la clave Fernet de 32 bytes desde SECRET_KEY, idéntica a la de a24c. */ +export function deriveFernetKey(secret: string): Buffer { + if (!secret || !secret.trim()) { + throw new Error('SECRET_KEY no está configurada (debe coincidir con la de a24c).'); + } + return createHash('sha256').update(secret, 'utf8').digest(); // 32 bytes +} + +/** + * Cifra `plaintext` como token Fernet. `timestampSec` permite inyectar el tiempo + * (segundos Unix) para pruebas deterministas; en producción se pasa el reloj real. + */ +export function fernetEncrypt(plaintext: string, key32: Buffer, timestampSec: number): string { + if (key32.length !== KEY_LENGTH) { + throw new Error(`La clave Fernet debe ser de ${KEY_LENGTH} bytes; se recibieron ${key32.length}.`); + } + const signingKey = key32.subarray(0, 16); + const encKey = key32.subarray(16, 32); + + const iv = randomBytes(IV_LENGTH); + const ts = Buffer.alloc(8); + ts.writeBigUInt64BE(BigInt(Math.floor(timestampSec))); + + const cipher = createCipheriv('aes-128-cbc', encKey, iv); // PKCS7 automático + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + + const parts = Buffer.concat([Buffer.from([FERNET_VERSION]), ts, iv, ciphertext]); + const hmac = createHmac('sha256', signingKey).update(parts).digest(); + + return toUrlSafeBase64(Buffer.concat([parts, hmac])); +} + +/** Descifra un token Fernet. Lanza si el HMAC no valida o el formato es inválido. */ +export function fernetDecrypt(token: string, key32: Buffer): string { + if (key32.length !== KEY_LENGTH) { + throw new Error(`La clave Fernet debe ser de ${KEY_LENGTH} bytes; se recibieron ${key32.length}.`); + } + const signingKey = key32.subarray(0, 16); + const encKey = key32.subarray(16, 32); + + const data = fromUrlSafeBase64(String(token ?? '')); + if (data.length < MIN_TOKEN_BYTES) throw new Error('Token Fernet demasiado corto.'); + if (data[0] !== FERNET_VERSION) throw new Error('Versión de token Fernet inválida.'); + + const hmacOffset = data.length - HMAC_LENGTH; + const signed = data.subarray(0, hmacOffset); + const providedHmac = data.subarray(hmacOffset); + const expectedHmac = createHmac('sha256', signingKey).update(signed).digest(); + if (!timingSafeEqual(providedHmac, expectedHmac)) { + throw new Error('HMAC del token Fernet no coincide (clave incorrecta o dato manipulado).'); + } + + const iv = data.subarray(9, 9 + IV_LENGTH); + const ciphertext = data.subarray(9 + IV_LENGTH, hmacOffset); + const decipher = createDecipheriv('aes-128-cbc', encKey, iv); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); +} + +/** + * Heurística sin clave para distinguir un token Fernet de texto plano legado: + * base64-url válido que decodifica a un blob con versión 0x80 y longitud mínima. + */ +export function isFernetToken(value: unknown): boolean { + if (typeof value !== 'string') return false; + const v = value.trim(); + if (v.length < 100 || !/^[A-Za-z0-9_-]+={0,2}$/.test(v)) return false; + try { + const data = fromUrlSafeBase64(v); + return data.length >= MIN_TOKEN_BYTES && data[0] === FERNET_VERSION; + } catch { + return false; + } +} diff --git a/src/lib/server/mssql-nodes.test.ts b/src/lib/server/mssql-nodes.test.ts index b3560fd..7dc9bd6 100644 --- a/src/lib/server/mssql-nodes.test.ts +++ b/src/lib/server/mssql-nodes.test.ts @@ -14,7 +14,7 @@ vi.mock('./crypto', async (importOriginal) => { }); import { decryptSecret } from './crypto'; -import { resolveNodeSqlPassword } from './mssql-nodes'; +import { resolveNodeSqlPassword, parseMssqlServer } from './mssql-nodes'; const decryptMock = vi.mocked(decryptSecret); @@ -52,3 +52,35 @@ describe('resolveNodeSqlPassword', () => { expect(decryptMock).toHaveBeenCalledOnce(); }); }); + +describe('parseMssqlServer', () => { + it('host,puerto → server y port separados (evita el bug host,puerto:1433)', () => { + expect(parseMssqlServer('192.168.1.20,1433')).toEqual({ + server: '192.168.1.20', + port: 1433 + }); + }); + + it('solo host → sin puerto (tedious usa 1433 por defecto)', () => { + expect(parseMssqlServer('SQLSERVER01')).toEqual({ server: 'SQLSERVER01' }); + }); + + it('host\\instancia → instanceName', () => { + expect(parseMssqlServer('HOST\\SQLEXPRESS')).toEqual({ + server: 'HOST', + instanceName: 'SQLEXPRESS' + }); + }); + + it('host\\instancia,puerto → server, instanceName y port', () => { + expect(parseMssqlServer('HOST\\SQLEXPRESS,1450')).toEqual({ + server: 'HOST', + port: 1450, + instanceName: 'SQLEXPRESS' + }); + }); + + it('recorta espacios e ignora puerto no numérico', () => { + expect(parseMssqlServer(' 10.0.0.5 , abc ')).toEqual({ server: '10.0.0.5' }); + }); +}); diff --git a/src/lib/server/mssql-nodes.ts b/src/lib/server/mssql-nodes.ts index aeb1fa5..d979461 100644 --- a/src/lib/server/mssql-nodes.ts +++ b/src/lib/server/mssql-nodes.ts @@ -36,6 +36,32 @@ export function adjustMssqlServerForDocker(serverName: string): string { return serverName.trim(); } +/** + * Descompone una dirección estilo SQL Server (`host,puerto` / `host\instancia` / combinaciones) + * en los campos separados que espera node-mssql (tedious). Es imprescindible: tedious NO entiende + * `host,puerto` en el campo `server` — lo toma como nombre de host literal y le añade el puerto + * por defecto (1433), produciendo intentos de conexión a `host,puerto:1433`. + */ +export function parseMssqlServer(address: string): { + server: string; + port?: number; + instanceName?: string; +} { + const raw = String(address ?? '').trim(); + const commaIdx = raw.indexOf(','); + const left = (commaIdx >= 0 ? raw.slice(0, commaIdx) : raw).trim(); + const portStr = commaIdx >= 0 ? raw.slice(commaIdx + 1).trim() : ''; + + const [host, instance] = left.split('\\', 2); + const result: { server: string; port?: number; instanceName?: string } = { + server: host.trim() + }; + const port = Number(portStr); + if (portStr && Number.isInteger(port) && port > 0) result.port = port; + if (instance && instance.trim()) result.instanceName = instance.trim(); + return result; +} + export function resolveMssqlUser(): string { return ( String(env.PANEL_MSSQL_USER || '').trim() @@ -63,8 +89,8 @@ export function resolveNodeSqlPassword(nodeSqlPassword: string | null | undefine ).trim(); } -function poolCacheKey(server: string, user: string, password: string): string { - return `${server}\t${user}\t${password}`; +function poolCacheKey(server: string, user: string, password: string, requestTimeoutMs?: number): string { + return `${server}\t${user}\t${password}\t${requestTimeoutMs ?? ''}`; } async function evictPoolIfNeeded(): Promise { @@ -85,7 +111,7 @@ async function evictPoolIfNeeded(): Promise { * Ejecuta `fn` sobre cada elemento con un máximo de `limit` tareas simultáneas. * Conserva el orden de `items` en el arreglo de resultados. */ -async function mapWithConcurrency( +export async function mapWithConcurrency( items: T[], limit: number, fn: (item: T, index: number) => Promise @@ -106,15 +132,24 @@ async function mapWithConcurrency( /** * Pool conectado a `master` en el servidor del nodo (permite consultar cualquier BD con nombre de tres partes). */ -export async function getMssqlPoolMaster(serverHost: string, password: string): Promise { - const user = resolveMssqlUser(); +export async function getMssqlPoolMaster( + serverHost: string, + password: string, + userOverride?: string, + requestTimeoutMs?: number +): Promise { + // El dashboard usa el usuario global (PANEL_MSSQL_USER); la depuración de duplicados conecta + // al servidor viejo con el usuario propio del restore_target, de ahí el override opcional. + const user = (userOverride && userOverride.trim()) || resolveMssqlUser(); if (!user || !password) { throw new Error( 'Falta PANEL_MSSQL_USER / PANEL_MSSQL_PASSWORD (o sql_password en database_nodes).' ); } const server = adjustMssqlServerForDocker(serverHost); - const key = poolCacheKey(server, user, password); + // El requestTimeout entra en la clave de caché: las operaciones largas (BACKUP/DROP) usan un + // pool distinto con timeout amplio, sin alterar el pool de consultas rápidas del dashboard. + const key = poolCacheKey(server, user, password, requestTimeoutMs); const existing = poolMap.get(key); if (existing) { @@ -127,18 +162,26 @@ export async function getMssqlPoolMaster(serverHost: string, password: string): poolMap.delete(key); } + // `server` puede venir como `host,puerto` (formato SQL Server); tedious necesita host y puerto + // en campos separados, o intentará conectar a `host,puerto:1433`. + const { server: host, port, instanceName } = parseMssqlServer(server); const cfg: sql.config = { user, password, - server, + server: host, + ...(port ? { port } : {}), database: 'master', // node-mssql gobierna el timeout de conexión con `connectionTimeout` (top-level); // se replica en options.connectTimeout (tedious) para cubrir ambas rutas. connectionTimeout: MSSQL_CONNECT_TIMEOUT_MS, + // requestTimeout por defecto de node-mssql es 15 s: insuficiente para BACKUP/DROP de bases + // reales. Cuando se pide, se amplía (el dashboard sigue con el default corto). + ...(requestTimeoutMs ? { requestTimeout: requestTimeoutMs } : {}), options: { encrypt: true, trustServerCertificate: true, - connectTimeout: MSSQL_CONNECT_TIMEOUT_MS + connectTimeout: MSSQL_CONNECT_TIMEOUT_MS, + ...(instanceName ? { instanceName } : {}) } }; @@ -286,6 +329,108 @@ function computeEffectivenessFromHistory( return effectivenessByDb; } +export type ServerDatabaseInfo = { + name: string; + size_mb: number; + last_restore_date: Date | null; + state_desc: string; + create_date: Date | null; +}; + +/** + * Lista las bases de USUARIO de un servidor (pool a master), con tamaño y último restore. + * Excluye las de sistema (database_id <= 4: master/tempdb/model/msdb). + */ +export async function listUserDatabasesOnServer( + pool: sql.ConnectionPool +): Promise { + const result = await pool.request().query(` + SELECT + d.name AS name, + CAST(( + SELECT SUM(mf.size) * 8.0 / 1024 + FROM sys.master_files mf + WHERE mf.database_id = d.database_id + ) AS DECIMAL(18,2)) AS size_mb, + ( + SELECT MAX(rh.restore_date) + FROM msdb.dbo.restorehistory rh + WHERE rh.destination_database_name = d.name + ) AS last_restore_date, + d.state_desc, + d.create_date + FROM sys.databases d + WHERE d.database_id > 4 + ORDER BY d.name + `); + return (result.recordset as any[]).map((r) => ({ + name: String(r.name), + size_mb: Number(r.size_mb) || 0, + last_restore_date: r.last_restore_date ?? null, + state_desc: String(r.state_desc ?? ''), + create_date: r.create_date ?? null + })); +} + +/** + * Borra una base en el servidor del pool. Fuerza SINGLE_USER (WITH ROLLBACK IMMEDIATE, cierra + * conexiones activas) y luego DROP DATABASE. + * + * Un identificador T-SQL NO se puede parametrizar, así que hay doble defensa contra inyección: + * (1) `databaseName` debe estar en `allowedNames` (lista real leída del propio servidor) y + * (2) dentro del batch se escapa con QUOTENAME. La validación ocurre ANTES de tocar el pool. + */ +export async function dropDatabaseOnServer( + pool: sql.ConnectionPool, + databaseName: string, + allowedNames: Set +): Promise { + const name = String(databaseName ?? '').trim(); + if (!name || !allowedNames.has(name)) { + throw new Error(`Base no permitida para borrado: "${name}".`); + } + const req = pool.request(); + req.input('dbname', sql.NVarChar(128), name); + await req.query(` + IF DB_ID(@dbname) IS NULL + THROW 50000, 'La base ya no existe en este servidor.', 1; + DECLARE @stmt NVARCHAR(MAX) = + N'ALTER DATABASE ' + QUOTENAME(@dbname) + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE;' + + N'DROP DATABASE ' + QUOTENAME(@dbname) + N';'; + EXEC sys.sp_executesql @stmt; + `); +} + +/** + * Respalda una base a `destPath` en el servidor del pool (COPY_ONLY para no romper la cadena de + * respaldos del cliente). El identificador NO se puede parametrizar: se valida contra `allowedNames` + * (lista real del servidor) y se escapa con QUOTENAME; la ruta destino SÍ va como parámetro. + */ +export async function backupDatabaseOnServer( + pool: sql.ConnectionPool, + databaseName: string, + allowedNames: Set, + destPath: string +): Promise { + const name = String(databaseName ?? '').trim(); + if (!name || !allowedNames.has(name)) { + throw new Error(`Base no permitida para respaldo: "${name}".`); + } + const dest = String(destPath ?? '').trim(); + if (!dest) throw new Error('Ruta de respaldo vacía.'); + const req = pool.request(); + req.input('dbname', sql.NVarChar(128), name); + req.input('dest', sql.NVarChar(4000), dest); + await req.query(` + IF DB_ID(@dbname) IS NULL + THROW 50000, 'La base no existe en este servidor.', 1; + DECLARE @stmt NVARCHAR(MAX) = + N'BACKUP DATABASE ' + QUOTENAME(@dbname) + + N' TO DISK = @p_dest WITH COPY_ONLY, INIT, FORMAT, NAME = N''dedup-move'';'; + EXEC sys.sp_executesql @stmt, N'@p_dest NVARCHAR(4000)', @p_dest = @dest; + `); +} + export type SqlDashboardBundle = { databaseRows: any[]; summaryMain: { total_databases: number; total_size_gb: number }; diff --git a/src/lib/server/sftp-transfer.test.ts b/src/lib/server/sftp-transfer.test.ts new file mode 100644 index 0000000..e6b810c --- /dev/null +++ b/src/lib/server/sftp-transfer.test.ts @@ -0,0 +1,31 @@ +/** + * Pruebas de las funciones puras de armado de rutas para la transferencia SFTP. La I/O real + * (SFTP/zip) no se prueba aquí; se cubre la construcción de rutas que es donde vive el riesgo + * de separadores Windows/POSIX. + */ +import { describe, it, expect } from 'vitest'; +import { joinRemotePath, toSftpPath } from './sftp-transfer'; + +describe('joinRemotePath', () => { + it('usa backslash cuando la carpeta es estilo Windows', () => { + expect(joinRemotePath('D:\\SQLDATA', 'NODO001.bak')).toBe('D:\\SQLDATA\\NODO001.bak'); + }); + + it('respeta separadores finales duplicados', () => { + expect(joinRemotePath('D:\\SQLDATA\\\\', 'a.zip')).toBe('D:\\SQLDATA\\a.zip'); + }); + + it('usa slash cuando la carpeta es POSIX', () => { + expect(joinRemotePath('/var/inbox/', 'a.zip')).toBe('/var/inbox/a.zip'); + }); +}); + +describe('toSftpPath', () => { + it('convierte backslashes de Windows a slashes para OpenSSH SFTP', () => { + expect(toSftpPath('D:\\SQLDATA\\NODO001.bak')).toBe('D:/SQLDATA/NODO001.bak'); + }); + + it('deja intactas las rutas POSIX', () => { + expect(toSftpPath('/var/inbox/a.zip')).toBe('/var/inbox/a.zip'); + }); +}); diff --git a/src/lib/server/sftp-transfer.ts b/src/lib/server/sftp-transfer.ts new file mode 100644 index 0000000..bde5a40 --- /dev/null +++ b/src/lib/server/sftp-transfer.ts @@ -0,0 +1,103 @@ +/** + * Transferencia de respaldos entre servidores por SFTP, usando las credenciales SSH que ya guarda + * cada restore_target. Se usa para mover una base del servidor viejo al nuevo: bajar el .bak del + * viejo, comprimirlo y subir el .zip a la carpeta de Entrada del nuevo (donde CloudRestoreAS lo + * restaura). Las funciones de armado de rutas son puras (probadas aparte). + */ +import { createWriteStream } from 'node:fs'; +import Client from 'ssh2-sftp-client'; +import archiver from 'archiver'; + +export type SftpCreds = { + host: string; + port: number; + username: string; + password: string; +}; + +const SFTP_READY_TIMEOUT_MS = 20000; + +/** Une carpeta + nombre respetando el separador dominante (Windows `\` o POSIX `/`). */ +export function joinRemotePath(folder: string, name: string): string { + const raw = String(folder ?? '').trim(); + const sep = raw.includes('\\') ? '\\' : '/'; + const trimmed = raw.replace(/[\\/]+$/, ''); + return `${trimmed}${sep}${name}`; +} + +/** Convierte una ruta Windows (`D:\x\y`) a la forma con `/` que acepta OpenSSH SFTP. */ +export function toSftpPath(p: string): string { + return String(p ?? '').replace(/\\/g, '/'); +} + +/** Abre una sesión SFTP, ejecuta `fn` y siempre cierra la conexión. */ +export async function withSftp(creds: SftpCreds, fn: (sftp: Client) => Promise): Promise { + const client = new Client(); + try { + await client.connect({ + host: creds.host, + port: creds.port || 22, + username: creds.username, + password: creds.password, + readyTimeout: SFTP_READY_TIMEOUT_MS + }); + return await fn(client); + } finally { + try { + await client.end(); + } catch { + /* ignore */ + } + } +} + +/** Baja un archivo remoto a una ruta local. */ +export async function sftpDownload(creds: SftpCreds, remotePath: string, localPath: string): Promise { + await withSftp(creds, (sftp) => sftp.fastGet(toSftpPath(remotePath), localPath)); +} + +/** + * Sube un archivo de forma atómica: primero a `.part` y luego rename a ``, para + * que CloudRestoreAS nunca vea un archivo a medio escribir en su carpeta de Entrada. + * + * El rename usa `posix-rename@openssh.com` (posixRename), que SOBRESCRIBE el destino: el + * SSH_FXP_RENAME estándar de OpenSSH falla si el `.zip` ya existe (de un intento previo o de una + * copia sin consumir). Si el servidor no tuviera la extensión, se cae a borrar-y-renombrar. + */ +export async function sftpUploadAtomic(creds: SftpCreds, localPath: string, remotePath: string): Promise { + const finalPath = toSftpPath(remotePath); + const tmpPath = `${finalPath}.part`; + await withSftp(creds, async (sftp) => { + await sftp.fastPut(localPath, tmpPath); + try { + await sftp.posixRename(tmpPath, finalPath); + } catch { + // Servidor sin posix-rename: borrar el destino (si existe) y renombrar clásico. + try { + await sftp.delete(finalPath); + } catch { + /* no existía */ + } + await sftp.rename(tmpPath, finalPath); + } + }); +} + +/** Borra un archivo remoto (limpieza del .bak temporal en el servidor viejo). */ +export async function sftpDelete(creds: SftpCreds, remotePath: string): Promise { + await withSftp(creds, (sftp) => sftp.delete(toSftpPath(remotePath))); +} + +/** Comprime un único archivo en un .zip con el nombre de entrada indicado. */ +export async function zipSingleFile(srcPath: string, entryName: string, destZipPath: string): Promise { + await new Promise((resolve, reject) => { + const output = createWriteStream(destZipPath); + const archive = archiver('zip', { zlib: { level: 6 } }); + output.on('close', () => resolve()); + output.on('error', reject); + archive.on('error', reject); + archive.pipe(output); + archive.file(srcPath, { name: entryName }); + archive.finalize(); + }); +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 6f5ecf7..4f691d6 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -331,14 +331,19 @@ export const load: PageServerLoad = async ({ cookies }) => { // filesystem porque las carpetas Procesados/Fallados viven en los servidores de restauración // (rutas Windows remotas) que el host del panel no ve. Si la BD falla, se registra en // `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) - ]); - } catch (e: any) { - console.error('Error cargando inventario de restauraciones:', e); - errors.restores = `No se pudo cargar el inventario de restauraciones: ${e?.message ?? e}`; + // Inventario de restauraciones (Respaldos Restaurados / Restores Fallidos): vistas SOLO de + // administrador. No se cargan ni se envían al cliente para usuarios normales (autorización en + // backend, no solo ocultar en la UI — OWASP Broken Access Control). + if (currentUser.es_admin) { + try { + [restoredBackups, failedRestores] = await Promise.all([ + listRestoredRestoreJobLogs(200), + listFailedRestoreJobLogs(200) + ]); + } catch (e: any) { + console.error('Error cargando inventario de restauraciones:', e); + errors.restores = `No se pudo cargar el inventario de restauraciones: ${e?.message ?? e}`; + } } // CALCULAR MÉTRICAS DE RESTAURACIÓN basadas en last_restore_date (ANTES del filtro) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index adaec1f..9e5f437 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1556,7 +1556,7 @@ {/if} - {#if activeView === 'restored'} + {#if activeView === 'restored' && data.currentUser?.es_admin}

Respaldos restaurados

@@ -1617,7 +1617,7 @@
{/if} - {#if activeView === 'failed'} + {#if activeView === 'failed' && data.currentUser?.es_admin}

Restores fallidos

diff --git a/src/routes/reportes/+page.server.ts b/src/routes/reportes/+page.server.ts index 5af7ca2..e9c5525 100644 --- a/src/routes/reportes/+page.server.ts +++ b/src/routes/reportes/+page.server.ts @@ -13,6 +13,8 @@ export const load: PageServerLoad = async ({ cookies }) => { const currentUser = await getUserById(session.userId); if (!currentUser || !currentUser.activo) throw redirect(303, '/login'); + // Reportes es solo para administradores (autorización en backend, no solo ocultar el enlace). + if (!currentUser.es_admin) throw redirect(303, '/'); // Obtener lista de bases activas para mostrar en UI let bases: { ID: number; Nombre: string; NodoSubNodo: string; BDName: string }[] = []; diff --git a/src/routes/reportes/excel/+server.ts b/src/routes/reportes/excel/+server.ts index 04c4452..dd8d46c 100644 --- a/src/routes/reportes/excel/+server.ts +++ b/src/routes/reportes/excel/+server.ts @@ -1,7 +1,6 @@ import { listDatabaseNodesForMssql } from '$lib/server/controldesk-pg'; import { getMssqlPoolMaster, resolveNodeSqlPassword } from '$lib/server/mssql-nodes'; -import { verifyToken } from '$lib/server/auth'; -import { redirect } from '@sveltejs/kit'; +import { getAdminFromCookies } from '$lib/server/report-excel'; import type { RequestHandler } from './$types'; import ExcelJS from 'exceljs'; @@ -41,11 +40,14 @@ function styleHeader(row: ExcelJS.Row) { } export const GET: RequestHandler = async ({ cookies, url }) => { - // Auth - const token = cookies.get('session_token'); - if (!token) throw redirect(303, '/login'); - const session = verifyToken(token); - if (!session) throw redirect(303, '/login'); + // Reporte solo para administradores (autorización en backend, no solo ocultar el enlace). + const admin = await getAdminFromCookies(cookies); + if (!admin) { + return new Response(JSON.stringify({ error: 'No autorizado: requiere sesión de administrador.' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } // Parámetros de filtro opcionales const filterAnio = url.searchParams.get('anio') || ''; diff --git a/src/routes/servidores-restauracion/depuracion/+page.server.ts b/src/routes/servidores-restauracion/depuracion/+page.server.ts new file mode 100644 index 0000000..bfac5f3 --- /dev/null +++ b/src/routes/servidores-restauracion/depuracion/+page.server.ts @@ -0,0 +1,116 @@ +/** + * Depuración de bases duplicadas (solo administradores). Tras mover bases a un servidor nuevo, + * las copias siguen en el viejo. Aquí se escanea un servidor viejo (restore_target), se muestra + * qué bases ya están bien en el nuevo y se borran las copias del viejo de forma segura. + */ +import { redirect, fail } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; +import type { PageServerLoad, Actions } from './$types'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { listRestoreTargets } from '$lib/server/controldesk-pg'; +import { scanDuplicates, dropDuplicates } from '$lib/server/dedup-databases'; +import { moveDatabaseToNewServer } from '$lib/server/db-move'; +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; +} + +export const load: PageServerLoad = async ({ cookies }) => { + const currentUser = await requireAdmin(cookies); + let dbWarning: string | null = null; + + let targets: Awaited> = []; + try { + targets = await listRestoreTargets(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error({ message: 'dedup: error listando restore_targets', context: { error: msg } }); + dbWarning = `No se pudo cargar servidores: ${msg}`; + } + + return { targets, currentUser, dbWarning }; +}; + +function parseTargetId(data: FormData): number { + const id = parseInt(data.get('targetId')?.toString() || '0', 10); + return Number.isInteger(id) && id > 0 ? id : 0; +} + +export const actions: Actions = { + /** Escanea un servidor viejo y reconcilia sus bases contra el destino nuevo del catálogo. */ + scan: async ({ request, cookies }) => { + await requireAdmin(cookies); + const traceId = randomUUID(); + const data = await request.formData(); + const targetId = parseTargetId(data); + if (!targetId) return fail(400, { error: 'Selecciona un servidor válido.', traceId }); + + try { + const result = await scanDuplicates(targetId); + return { scan: result }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logger.error({ trace_id: traceId, message: 'dedup: fallo al escanear', context: { targetId, error: msg } }); + return fail(500, { error: `No se pudo escanear el servidor: ${msg}`, traceId }); + } + }, + + /** Borra del servidor viejo las bases seleccionadas (re-verifica seguridad en el servidor). */ + drop: async ({ request, cookies }) => { + const currentUser = await requireAdmin(cookies); + const traceId = randomUUID(); + const data = await request.formData(); + const targetId = parseTargetId(data); + if (!targetId) return fail(400, { error: 'Selecciona un servidor válido.', traceId }); + + let names: string[]; + try { + const raw = data.get('names')?.toString() || '[]'; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) throw new Error('names no es un arreglo'); + names = parsed.map((n) => String(n).trim()).filter(Boolean); + } catch { + return fail(400, { error: 'Lista de bases inválida.', traceId }); + } + if (names.length === 0) return fail(400, { error: 'No hay bases seleccionadas.', traceId }); + + try { + const outcomes = await dropDuplicates(targetId, names, currentUser.username); + return { drop: { targetId, outcomes } }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logger.error({ trace_id: traceId, message: 'dedup: fallo al borrar', context: { targetId, error: msg } }); + return fail(500, { error: `No se pudieron borrar las bases: ${msg}`, traceId }); + } + }, + + /** Manda una base que solo está en el viejo hacia su servidor nuevo (vía CRA) y, al confirmar, la borra del viejo. */ + move: async ({ request, cookies }) => { + const currentUser = await requireAdmin(cookies); + const traceId = randomUUID(); + const data = await request.formData(); + const targetId = parseTargetId(data); + const name = data.get('name')?.toString().trim(); + if (!targetId) return fail(400, { error: 'Selecciona un servidor válido.', traceId }); + if (!name) return fail(400, { error: 'Base requerida.', traceId }); + + try { + const result = await moveDatabaseToNewServer(targetId, name, currentUser.username); + return { move: result }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logger.error({ trace_id: traceId, message: 'dedup: fallo al mover', context: { targetId, name, error: msg } }); + return fail(500, { error: `No se pudo mandar la base al nuevo: ${msg}`, traceId }); + } + } +}; diff --git a/src/routes/servidores-restauracion/depuracion/+page.svelte b/src/routes/servidores-restauracion/depuracion/+page.svelte new file mode 100644 index 0000000..18c059a --- /dev/null +++ b/src/routes/servidores-restauracion/depuracion/+page.svelte @@ -0,0 +1,407 @@ + + + +
+ + +
+

Depuración de bases duplicadas

+

+ Al mover bases a un servidor nuevo, las copias quedaron también en el viejo. Elige el + servidor viejo, revisa qué bases ya están bien en su servidor nuevo y borra las copias + del viejo. +

+
+ +
+ warning + + El borrado hace DROP DATABASE en el servidor viejo y cierra las + conexiones activas (SINGLE_USER). Es irreversible. Solo se habilita el borrado de bases + que ya existen en el nuevo con un tamaño coherente. + +
+ + {#if data.dbWarning} +
+ {data.dbWarning} +
+ {/if} + {#if errorMsg} +
+ {errorMsg} +
+ {/if} + + +
+
+ + +
+
+ + + {#if outcomes.length > 0} +
+

Resultado del borrado

+
    + {#each outcomes as o (o.name)} +
  • + + {o.ok ? 'check_circle' : 'error'} + + {o.name} + + {o.ok ? 'borrada del servidor viejo' : o.message || o.status} + +
  • + {/each} +
+
+ {/if} + + {#if scannedTarget} +
+

+ Servidor viejo: {scannedTarget.name} + ({scannedTarget.server_ip}) + · {rows.length} base{rows.length === 1 ? '' : 's'} · {deletableRows.length} segura{deletableRows.length === + 1 + ? '' + : 's'} +

+
+ + +
+
+ + {#if rows.length === 0} +

+ No hay bases de usuario en este servidor. +

+ {:else} +
+ + + + + + + + + + + + + + + + {#each rows as row (row.name)} + {@const meta = STATUS_META[row.status]} + + + + + + + + + + + + {/each} + +
BaseViejo: tamañoViejo: últ. restoreServidor nuevoNuevo: tamañoNuevo: últ. restoreEstadoAcciones
+ toggle(row.name)} + title={row.deletable + ? 'Marcar para borrar del servidor viejo' + : 'Solo se pueden borrar bases seguras'} + class="h-4 w-4 rounded border-slate-300 disabled:opacity-40" + /> + {row.name}{formatSize(row.oldSizeMb)}{formatDate(row.oldLastRestore)} + {#if row.newServer} + {row.newServer} + {#if row.newServerLabel} + {row.newServerLabel} + {/if} + {:else} + — + {/if} + {formatSize(row.newSizeMb)}{formatDate(row.newLastRestore)} + + {meta.icon} + {meta.label} + + + {#if row.movable} +
+ + + +
+ {/if} + {#if moveNotes[row.name]} +

{moveNotes[row.name].message}

+ {/if} +
+
+ {/if} + {/if} +
+ + + {#if confirmOpen} +
+
+
+ delete_forever +

Confirmar borrado

+
+

+ Se hará DROP DATABASE en + {scannedTarget?.name} + ({scannedTarget?.server_ip}) + de estas {selected.size} base{selected.size === 1 ? '' : 's'}. Esta acción es + irreversible. +

+
    + {#each selectedNames as name (name)} +
  • {name}
  • + {/each} +
+
+ +
+ + + +
+
+
+
+ {/if} +