feat(env): update environment configuration and add SQL Server password handling (#15)
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
- 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: #15 Co-authored-by: AlexeerCT <acazares@aduanasoft.com.mx> Co-committed-by: AlexeerCT <acazares@aduanasoft.com.mx>
This commit is contained in:
@@ -612,6 +612,46 @@ export async function getRestoreTargetById(id: number): Promise<RestoreTarget |
|
||||
return (r.rows[0] as RestoreTarget) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Servidor de restauración por id con la contraseña SQL descifrada. Uso exclusivo del servidor
|
||||
* (conectar a SQL Server para depurar bases duplicadas); NUNCA se expone al cliente.
|
||||
*/
|
||||
export async function getRestoreTargetWithPasswordById(
|
||||
id: number
|
||||
): Promise<(RestoreTarget & { sql_password: string }) | null> {
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
190
src/lib/server/db-move.ts
Normal file
190
src/lib/server/db-move.ts
Normal file
@@ -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<void> {
|
||||
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<MoveResult> {
|
||||
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.' };
|
||||
}
|
||||
151
src/lib/server/dedup-databases.test.ts
Normal file
151
src/lib/server/dedup-databases.test.ts
Normal file
@@ -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<string, any>([['ventasdb', { BDName: 'VentasDB' }]]);
|
||||
const all = new Map<string, any>([
|
||||
['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');
|
||||
});
|
||||
});
|
||||
320
src/lib/server/dedup-databases.ts
Normal file
320
src/lib/server/dedup-databases.ts
Normal file
@@ -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<string, any> {
|
||||
const byName = new Map<string, any>();
|
||||
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<string, any>,
|
||||
allByName: Map<string, any>
|
||||
): 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<NewServerCheck> {
|
||||
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<ScanResult> {
|
||||
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<DuplicateRow> => {
|
||||
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<DropOutcome[]> {
|
||||
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;
|
||||
}
|
||||
74
src/lib/server/fernet.test.ts
Normal file
74
src/lib/server/fernet.test.ts
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
115
src/lib/server/fernet.ts
Normal file
115
src/lib/server/fernet.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -85,7 +111,7 @@ async function evictPoolIfNeeded(): Promise<void> {
|
||||
* 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<T, R>(
|
||||
export async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T, index: number) => Promise<R>
|
||||
@@ -106,15 +132,24 @@ async function mapWithConcurrency<T, R>(
|
||||
/**
|
||||
* 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<sql.ConnectionPool> {
|
||||
const user = resolveMssqlUser();
|
||||
export async function getMssqlPoolMaster(
|
||||
serverHost: string,
|
||||
password: string,
|
||||
userOverride?: string,
|
||||
requestTimeoutMs?: number
|
||||
): Promise<sql.ConnectionPool> {
|
||||
// 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<ServerDatabaseInfo[]> {
|
||||
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<string>
|
||||
): Promise<void> {
|
||||
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<string>,
|
||||
destPath: string
|
||||
): Promise<void> {
|
||||
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 };
|
||||
|
||||
31
src/lib/server/sftp-transfer.test.ts
Normal file
31
src/lib/server/sftp-transfer.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
103
src/lib/server/sftp-transfer.ts
Normal file
103
src/lib/server/sftp-transfer.ts
Normal file
@@ -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<T>(creds: SftpCreds, fn: (sftp: Client) => Promise<T>): Promise<T> {
|
||||
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<void> {
|
||||
await withSftp(creds, (sftp) => sftp.fastGet(toSftpPath(remotePath), localPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sube un archivo de forma atómica: primero a `<remote>.part` y luego rename a `<remote>`, 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await new Promise<void>((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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user