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} +