From 969acdbdf465a4ef6cf8aea437389f76f3018ebb Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 5 Jun 2026 10:50:27 -0600 Subject: [PATCH 1/3] feature/integracion-cloudrestore-targets --- .env.example | 14 + Dockerfile | 6 +- README.md | 8 +- .../migrations/001_restore_targets.down.sql | 8 + .../migrations/001_restore_targets.up.sql | 49 ++ .../002_cloudrestore_status.down.sql | 3 + .../migrations/002_cloudrestore_status.up.sql | 17 + database/schema.sql | 72 +++ database/seeds/dev_cra_alfa_local.sql | 47 ++ docker-compose.yml | 4 + package-lock.json | 395 +++++++++++++++- package.json | 9 +- scripts/docker-entrypoint.sh | 8 + scripts/wait-for-a24c-schema.js | 98 ++++ src/lib/server/api-error.ts | 18 + src/lib/server/controldesk-pg.test.ts | 32 ++ src/lib/server/controldesk-pg.ts | 441 +++++++++++++++++- src/lib/server/crypto-core.test.ts | 87 ++++ src/lib/server/crypto-core.ts | 86 ++++ src/lib/server/crypto.ts | 26 ++ src/lib/server/logger.ts | 36 ++ src/lib/server/service-auth.ts | 40 ++ src/routes/+page.server.ts | 48 +- src/routes/+page.svelte | 102 ++-- .../api/restore/instance-config/+server.ts | 78 ++++ src/routes/api/restore/job-result/+server.ts | 97 ++++ .../api/restore/resolve-route/+server.ts | 81 ++++ .../api/restore/target-catalog/+server.ts | 41 ++ src/routes/api/restore/target-for/+server.ts | 72 +++ .../servidores-restauracion/+page.server.ts | 147 ++++++ .../servidores-restauracion/+page.svelte | 366 +++++++++++++++ vitest.config.ts | 10 + 32 files changed, 2484 insertions(+), 62 deletions(-) create mode 100644 database/migrations/001_restore_targets.down.sql create mode 100644 database/migrations/001_restore_targets.up.sql create mode 100644 database/migrations/002_cloudrestore_status.down.sql create mode 100644 database/migrations/002_cloudrestore_status.up.sql create mode 100644 database/seeds/dev_cra_alfa_local.sql create mode 100644 scripts/docker-entrypoint.sh create mode 100644 scripts/wait-for-a24c-schema.js create mode 100644 src/lib/server/api-error.ts create mode 100644 src/lib/server/controldesk-pg.test.ts create mode 100644 src/lib/server/crypto-core.test.ts create mode 100644 src/lib/server/crypto-core.ts create mode 100644 src/lib/server/crypto.ts create mode 100644 src/lib/server/logger.ts create mode 100644 src/lib/server/service-auth.ts create mode 100644 src/routes/api/restore/instance-config/+server.ts create mode 100644 src/routes/api/restore/job-result/+server.ts create mode 100644 src/routes/api/restore/resolve-route/+server.ts create mode 100644 src/routes/api/restore/target-catalog/+server.ts create mode 100644 src/routes/api/restore/target-for/+server.ts create mode 100644 src/routes/servidores-restauracion/+page.server.ts create mode 100644 src/routes/servidores-restauracion/+page.svelte create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example index 203db81..74c1405 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,17 @@ SMTP_USER= SMTP_PASSWORD= SMTP_FROM= SMTP_USE_TLS=true + +# Integración CloudRestoreAS +# Token Bearer que CloudRestoreAS envía en Authorization: Bearer +# para /api/restore/target-for, /api/restore/job-result y /api/restore/instance-config. +# DEBE ser idéntico al valor "Token API del PANEL" en CloudRestoreAS (pestaña Config). +# Generar uno largo y aleatorio, p. ej.: +# 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: +# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +ENCRYPTION_KEY= diff --git a/Dockerfile b/Dockerfile index 5018da0..51b20a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,11 +27,13 @@ WORKDIR /app COPY --from=builder /app/build build/ COPY --from=builder /app/node_modules node_modules/ COPY --from=builder /app/server.js . +COPY --from=builder /app/scripts scripts/ COPY --from=builder /app/certs certs/ COPY package.json . +RUN chmod +x /app/scripts/docker-entrypoint.sh + # Expose HTTPS port EXPOSE 3000 -# Start the application with HTTPS -CMD ["node", "server.js"] +ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 930a5ec..fe3c0c6 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,13 @@ docker push dev.aduanasoft.com:8443/databases_a24c/frontend:latest - `src/lib/components/`: Componentes reutilizables (`Navbar`, `Sidebar`). - `src/app.css`: Estilos globales y tema Glass. +## Integración CloudRestoreAS + +- Endpoints servicio-a-servicio bajo `/api/restore/` (token `CLOUDRESTORE_API_TOKEN` en `.env`). +- **Servidores de Restauración** (`/servidores-restauracion`): admin de Alfa/Omega/Gamma y carpeta de entrada reportada por CloudRestoreAS (solo lectura). +- Contrato y prueba local: ver `INTEGRACION_PANEL.md` en el repo CloudRecoveryAS (mismo token en `CLOUDRESTORE_API_TOKEN` y en la config PANEL de CloudRestoreAS). + ## Notas de Migración - **DataTables**: Se inicializan en el cliente dentro de `onMount` para mantener la compatibilidad con las tablas interactivas originales. -- **Sistema de Archivos**: La lectura de backups busca en `D:/BackupSFTP/` en el servidor donde corre Node.js. +- **Sistema de Archivos**: La lectura de backups busca en `BACKUP_PATH` en el servidor donde corre Node.js (independiente de la carpeta que vigila CloudRestoreAS). diff --git a/database/migrations/001_restore_targets.down.sql b/database/migrations/001_restore_targets.down.sql new file mode 100644 index 0000000..83c2449 --- /dev/null +++ b/database/migrations/001_restore_targets.down.sql @@ -0,0 +1,8 @@ +-- Migración 001 (DOWN): revierte la integración CloudRestoreAS. +-- Se quita primero la columna de asignación y la tabla dependiente. + +ALTER TABLE a24c.database_nodes DROP COLUMN IF EXISTS restore_target_id; + +DROP TABLE IF EXISTS a24c.restore_job_logs; + +DROP TABLE IF EXISTS a24c.restore_targets; diff --git a/database/migrations/001_restore_targets.up.sql b/database/migrations/001_restore_targets.up.sql new file mode 100644 index 0000000..a3c458d --- /dev/null +++ b/database/migrations/001_restore_targets.up.sql @@ -0,0 +1,49 @@ +-- Migración 001 (UP): integración CloudRestoreAS. +-- Servidores de restauración (Alfa/Omega/Gamma) + asignación por base de datos +-- (database_nodes.restore_target_id) + bitácora de jobs. Idempotente. +-- Rollback en 001_restore_targets.down.sql. + +CREATE SCHEMA IF NOT EXISTS a24c; + +CREATE TABLE IF NOT EXISTS a24c.restore_targets ( + id SERIAL PRIMARY KEY, + name VARCHAR(120) NOT NULL UNIQUE, + server_ip VARCHAR(255), + sql_username VARCHAR(128), + sql_password_encrypted TEXT, + data_folder VARCHAR(500), + ssh_host VARCHAR(255), + ssh_port INTEGER DEFAULT 22, + ssh_username VARCHAR(128), + ssh_password_encrypted TEXT, + remote_inbox_path VARCHAR(500), + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO a24c.restore_targets (name) VALUES ('Alfa'), ('Omega'), ('Gamma') +ON CONFLICT (name) DO NOTHING; + +-- Columna de asignación en database_nodes (tabla aprovisionada por el backend a24c). +ALTER TABLE a24c.database_nodes + ADD COLUMN IF NOT EXISTS restore_target_id INTEGER + REFERENCES a24c.restore_targets (id) ON DELETE SET NULL; + +CREATE TABLE IF NOT EXISTS a24c.restore_job_logs ( + id SERIAL PRIMARY KEY, + filename VARCHAR(500) NOT NULL, + restore_target_id INTEGER REFERENCES a24c.restore_targets (id) ON DELETE SET NULL, + db_name VARCHAR(255), + status VARCHAR(20) NOT NULL CHECK (status IN ('completed', 'failed', 'forwarded')), + duration_ms INTEGER, + error_message TEXT, + restored_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_target + ON a24c.restore_job_logs (restore_target_id); +CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_restored_at + ON a24c.restore_job_logs (restored_at); +CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_status + ON a24c.restore_job_logs (status); diff --git a/database/migrations/002_cloudrestore_status.down.sql b/database/migrations/002_cloudrestore_status.down.sql new file mode 100644 index 0000000..9865934 --- /dev/null +++ b/database/migrations/002_cloudrestore_status.down.sql @@ -0,0 +1,3 @@ +SET search_path TO a24c, public; + +DROP TABLE IF EXISTS a24c.cloudrestore_status; diff --git a/database/migrations/002_cloudrestore_status.up.sql b/database/migrations/002_cloudrestore_status.up.sql new file mode 100644 index 0000000..096fc9f --- /dev/null +++ b/database/migrations/002_cloudrestore_status.up.sql @@ -0,0 +1,17 @@ +-- Estado reportado por CloudRestoreAS (carpeta de entrada, host, versión). +-- Solo CloudRestoreAS escribe vía POST /api/restore/instance-config; el panel solo consulta. +-- Rollback en 002_cloudrestore_status.down.sql. + +SET search_path TO a24c, public; + +CREATE TABLE IF NOT EXISTS a24c.cloudrestore_status ( + id SERIAL PRIMARY KEY, + instance_key VARCHAR(120) NOT NULL UNIQUE DEFAULT 'default', + input_folder VARCHAR(500) NOT NULL, + host_name VARCHAR(255), + app_version VARCHAR(50), + reported_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE a24c.cloudrestore_status IS + 'Carpeta de entrada vigente reportada por CloudRestoreAS. Solo lectura en el panel.'; diff --git a/database/schema.sql b/database/schema.sql index 6ab2ca0..bc5c6c3 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -62,3 +62,75 @@ VALUES ( true ) ON CONFLICT (username) DO NOTHING; + +-- ============================================================================ +-- Integración CloudRestoreAS: servidores de restauración y bitácora de jobs. +-- Equivalente versionado en database/migrations/001_restore_targets.{up,down}.sql +-- ============================================================================ + +-- Servidores SQL Server destino de restauración (Alfa, Omega, Gamma). Son máquinas +-- EXTERNAS independientes: el .bak se transfiere por SFTP/SSH al servidor y el SQL Server +-- restaura desde su disco local. Cada base (a24c.database_nodes.restore_target_id) se asigna +-- a uno de ellos. Las contraseñas (SQL y SSH) se guardan cifradas con AES-256-GCM, nunca en plano. +CREATE TABLE IF NOT EXISTS a24c.restore_targets ( + id SERIAL PRIMARY KEY, + name VARCHAR(120) NOT NULL UNIQUE, -- Alfa | Omega | Gamma + server_ip VARCHAR(255), -- SQL Server: IP/hostname; acepta "ip,puerto" + sql_username VARCHAR(128), + sql_password_encrypted TEXT, -- sobre gcm:iv:tag:ciphertext + data_folder VARCHAR(500), -- ruta .mdf/.ldf EN el server remoto (C:\SQLData) + ssh_host VARCHAR(255), -- host SSH del servidor (suele ser el mismo equipo) + ssh_port INTEGER DEFAULT 22, + ssh_username VARCHAR(128), + ssh_password_encrypted TEXT, -- credencial SSH cifrada (AES-256-GCM) + remote_inbox_path VARCHAR(500), -- ruta en el server donde se sube el .bak y se restaura (C:\RestoreInbox) + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Tres servidores fijos de partida (editables). Datos de conexión vacíos hasta configurarse. +INSERT INTO a24c.restore_targets (name) VALUES ('Alfa'), ('Omega'), ('Gamma') +ON CONFLICT (name) DO NOTHING; + +-- Asignación servidor de restauración ↔ base de datos (la tabla database_nodes la aprovisiona +-- el backend a24c; aquí solo se agrega la columna de asignación). +ALTER TABLE a24c.database_nodes + ADD COLUMN IF NOT EXISTS restore_target_id INTEGER + REFERENCES a24c.restore_targets (id) ON DELETE SET NULL; + +-- Bitácora de restauraciones reportadas por CloudRestoreAS. +CREATE TABLE IF NOT EXISTS a24c.restore_job_logs ( + id SERIAL PRIMARY KEY, + filename VARCHAR(500) NOT NULL, + restore_target_id INTEGER REFERENCES a24c.restore_targets (id) ON DELETE SET NULL, + db_name VARCHAR(255), + status VARCHAR(20) NOT NULL CHECK (status IN ('completed', 'failed', 'forwarded')), + duration_ms INTEGER, + error_message TEXT, + restored_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_target + ON a24c.restore_job_logs (restore_target_id); +CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_restored_at + ON a24c.restore_job_logs (restored_at); +CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_status + ON a24c.restore_job_logs (status); + +COMMENT ON TABLE a24c.restore_targets IS 'Servidores SQL Server destino de restauración (CloudRestoreAS). Contraseña cifrada AES-256-GCM.'; +COMMENT ON TABLE a24c.restore_job_logs IS 'Bitácora de restauraciones reportadas por CloudRestoreAS.'; + +-- Equivalente versionado en database/migrations/002_cloudrestore_status.{up,down}.sql +-- Carpeta de entrada reportada por CloudRestoreAS (solo lectura en el panel). +CREATE TABLE IF NOT EXISTS a24c.cloudrestore_status ( + id SERIAL PRIMARY KEY, + instance_key VARCHAR(120) NOT NULL UNIQUE DEFAULT 'default', + input_folder VARCHAR(500) NOT NULL, + host_name VARCHAR(255), + app_version VARCHAR(50), + reported_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE a24c.cloudrestore_status IS + 'Carpeta de entrada vigente reportada por CloudRestoreAS. Solo lectura en el panel.'; diff --git a/database/seeds/dev_cra_alfa_local.sql b/database/seeds/dev_cra_alfa_local.sql new file mode 100644 index 0000000..856f50c --- /dev/null +++ b/database/seeds/dev_cra_alfa_local.sql @@ -0,0 +1,47 @@ +-- Seed dev: CRA local en esta máquina (instancia Alfa). +-- Aplica sobre a24c en Postgres. Idempotente donde es posible. +-- Contraseñas cifradas con ENCRYPTION_KEY del .env del panel (Soluciones01). + +SET search_path TO a24c, public; + +-- Bitácora: permitir status forwarded (reenvío SFTP). +ALTER TABLE a24c.restore_job_logs DROP CONSTRAINT IF EXISTS restore_job_logs_status_check; +ALTER TABLE a24c.restore_job_logs ADD CONSTRAINT restore_job_logs_status_check + CHECK (status IN ('completed', 'failed', 'forwarded')); + +-- Servidor Alfa: SQL local + SSH local (misma máquina Windows). +UPDATE a24c.restore_targets +SET + server_ip = 'localhost', + sql_username = 'sa', + sql_password_encrypted = 'gcm:hp9C+T5Zvdxqc/CS:S+56zEHeJFszt3MptzjS9A==:1UTe60xRrMSOW0/+', + data_folder = 'C:\Program Files\Microsoft SQL Server\MSSQL16.MSSQLSERVER\MSSQL\DATA', + ssh_host = 'localhost', + ssh_port = 22, + ssh_username = 'hugo_reyes', + ssh_password_encrypted = 'gcm:iJgYS7AJ1osrrwHj:8D4TiN9xOEr207+HBIxt8w==:/wt/4ZwPY4Xg4CLA', + remote_inbox_path = 'C:\CloudRestore\Entrada', + notes = 'CRA dev local — esta máquina', + updated_at = now() +WHERE name = 'Alfa'; + +-- Carpeta de entrada reportada (hasta que el CRA guarde config real). +INSERT INTO a24c.cloudrestore_status (instance_key, input_folder, host_name, app_version, reported_at) +VALUES ('Alfa', 'C:\CloudRestore\Entrada', 'DEV-LOCAL', '1.0.0-dev', now()) +ON CONFLICT (instance_key) DO UPDATE SET + input_folder = EXCLUDED.input_folder, + host_name = EXCLUDED.host_name, + app_version = EXCLUDED.app_version, + reported_at = now(); + +-- Nodos de prueba asignados a Alfa con SQL en localhost. +UPDATE a24c.database_nodes +SET + server_name = 'localhost', + is_active = 1 +WHERE restore_target_id = (SELECT id FROM a24c.restore_targets WHERE name = 'Alfa'); + +-- Asegurar nodo de prueba principal para restore_local. +UPDATE a24c.database_nodes +SET restore_target_id = (SELECT id FROM a24c.restore_targets WHERE name = 'Alfa') +WHERE node_subnode_key = 'GENERICA-TEST'; diff --git a/docker-compose.yml b/docker-compose.yml index b4df038..fe1af63 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,10 @@ services: - SMTP_PASSWORD=${SMTP_PASSWORD:-} - SMTP_FROM=${SMTP_FROM:-} - SMTP_USE_TLS=${SMTP_USE_TLS:-true} + + # Integración CloudRestoreAS + - CLOUDRESTORE_API_TOKEN=${CLOUDRESTORE_API_TOKEN} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} volumes: # Map the actual backup folder from host to the container's backup path # Local dev: carpeta escribible del repo con respaldos sintéticos (read-only en el contenedor) diff --git a/package-lock.json b/package-lock.json index 4039522..e77ac31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,8 @@ "tailwindcss": "^3.4.19", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^2.1.9" } }, "node_modules/@alloc/quick-lru": { @@ -1617,6 +1618,129 @@ "node": ">=20.0.0" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -1843,6 +1967,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2232,6 +2366,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2310,6 +2454,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chainsaw": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", @@ -2322,6 +2483,16 @@ "node": "*" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2586,6 +2757,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -2942,6 +3123,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3133,6 +3321,16 @@ "node": ">= 6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -4546,6 +4744,13 @@ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5149,6 +5354,23 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pg": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", @@ -6149,6 +6371,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -6205,6 +6434,13 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -6214,6 +6450,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6642,6 +6885,20 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6690,6 +6947,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -7065,6 +7352,29 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vitefu": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", @@ -7085,6 +7395,72 @@ } } }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/which-boxed-primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", @@ -7170,6 +7546,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 6e04699..8f44491 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "start": "node server.js", - "check": "svelte-kit sync && tsc --noEmit" + "start": "node scripts/wait-for-a24c-schema.js && node server.js", + "check": "svelte-kit sync && tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.3.1", @@ -27,7 +29,8 @@ "tailwindcss": "^3.4.19", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^2.1.9" }, "dependencies": { "bcrypt": "^6.0.0", diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..983b7fe --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "Panel — verificando dependencias de base de datos..." +node /app/scripts/wait-for-a24c-schema.js + +echo "Panel — iniciando servidor HTTPS..." +exec node /app/server.js diff --git a/scripts/wait-for-a24c-schema.js b/scripts/wait-for-a24c-schema.js new file mode 100644 index 0000000..39abb2c --- /dev/null +++ b/scripts/wait-for-a24c-schema.js @@ -0,0 +1,98 @@ +/** + * Espera a que PostgreSQL responda y que el esquema CloudRestore (Alembic a24c) + * esté aplicado antes de arrancar el panel HTTPS. + */ +import { fileURLToPath } from 'url'; +import pg from 'pg'; + +const { Pool } = pg; + +const REQUIRED_TABLES = ['restore_targets', 'cloudrestore_status', 'restore_job_logs']; + +function envInt(name, fallback) { + const raw = process.env[name]; + if (!raw) return fallback; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function poolConfig() { + return { + host: process.env.DB_POSTGRES_HOST || 'localhost', + port: Number.parseInt(process.env.DB_POSTGRES_PORT || '5432', 10), + database: process.env.DB_POSTGRES_DB || 'a24c', + user: process.env.DB_POSTGRES_USER || 'postgres', + password: process.env.DB_POSTGRES_PASS || 'postgres', + max: 1, + connectionTimeoutMillis: 5000, + }; +} + +async function postgresReady(pool) { + await pool.query('SELECT 1'); +} + +async function tablesReady(pool) { + const r = await pool.query( + ` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'a24c' + AND table_name = ANY($1::text[]) + `, + [REQUIRED_TABLES] + ); + return r.rows.length === REQUIRED_TABLES.length; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function waitForA24cSchema() { + const maxAttempts = envInt('A24C_SCHEMA_WAIT_ATTEMPTS', 60); + const intervalMs = envInt('A24C_SCHEMA_WAIT_INTERVAL_MS', 2000); + const pool = new Pool(poolConfig()); + + console.log('=========================================='); + console.log('Panel — esperando esquema a24c (CloudRestore)'); + console.log(` host: ${poolConfig().host}:${poolConfig().port}`); + console.log(` db: ${poolConfig().database}`); + console.log('=========================================='); + + try { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + await postgresReady(pool); + if (await tablesReady(pool)) { + console.log('✓ PostgreSQL y esquema CloudRestore listos'); + return; + } + console.log( + ` intento ${attempt}/${maxAttempts}: tablas CRA pendientes (levanta a24c-backend si aún no corre)` + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.log(` intento ${attempt}/${maxAttempts}: ${msg}`); + } + + if (attempt < maxAttempts) { + await sleep(intervalMs); + } + } + + throw new Error( + 'Timeout esperando esquema CloudRestore. Orden recomendado: ' + + 'a24c-postgres → a24c-backend (alembic upgrade head) → panel' + ); + } finally { + await pool.end(); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + waitForA24cSchema().catch((err) => { + console.error('❌', err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/src/lib/server/api-error.ts b/src/lib/server/api-error.ts new file mode 100644 index 0000000..d5978e5 --- /dev/null +++ b/src/lib/server/api-error.ts @@ -0,0 +1,18 @@ +/** + * Respuestas de error con la estructura estándar Aduanasoft §5: + * { error: { code, message, trace_id } } + */ +import { json } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; + +export function newTraceId(): string { + return randomUUID(); +} + +export function errorJson( + code: 400 | 401 | 403 | 404 | 409 | 422 | 500, + message: string, + traceId: string +) { + return json({ error: { code, message, trace_id: traceId } }, { status: code }); +} diff --git a/src/lib/server/controldesk-pg.test.ts b/src/lib/server/controldesk-pg.test.ts new file mode 100644 index 0000000..1ab1ba8 --- /dev/null +++ b/src/lib/server/controldesk-pg.test.ts @@ -0,0 +1,32 @@ +/** + * Pruebas de matchNodeRowFromBackupStem (resolución de nodo desde nombre de archivo). + */ +import { describe, it, expect } from 'vitest'; +import { matchNodeRowFromBackupStem } from './controldesk-pg'; + +const NODES = [ + { + NodoSubNodo: 'GENERICA-TEST', + BDName: 'GENERICA-TEST' + }, + { + NodoSubNodo: '08037NATM001', + BDName: 'CLIENTE_DB' + } +]; + +describe('matchNodeRowFromBackupStem', () => { + it('coincide por stem exacto', () => { + const row = matchNodeRowFromBackupStem('GENERICA-TEST', NODES); + expect(row?.NodoSubNodo).toBe('GENERICA-TEST'); + }); + + it('coincide por prefijo con sufijo', () => { + const row = matchNodeRowFromBackupStem('08037NATM001.KNOWNWORLD.000', NODES); + expect(row?.NodoSubNodo).toBe('08037NATM001'); + }); + + it('devuelve null si no hay match', () => { + expect(matchNodeRowFromBackupStem('DESCONOCIDO', NODES)).toBeNull(); + }); +}); diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts index 6dd3215..eb7ae07 100644 --- a/src/lib/server/controldesk-pg.ts +++ b/src/lib/server/controldesk-pg.ts @@ -2,8 +2,10 @@ * Catálogo ControlDesk en PostgreSQL (esquema a24c; DDL lo aprovisiona otra aplicación). * Devuelve columnas con alias en español para compatibilidad con la UI existente. */ +import path from 'node:path'; import { env } from '$env/dynamic/private'; import { pgPool } from './db'; +import { encryptSecret, decryptSecret } from './crypto'; function schemaName(): string { const s = 'a24c'; @@ -21,6 +23,89 @@ function qUsers(): string { return `"${s.replace(/"/g, '""')}"."portal_users"`; } +function qRestoreTargets(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."restore_targets"`; +} + +function qRestoreJobLogs(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."restore_job_logs"`; +} + +function qCloudRestoreStatus(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."cloudrestore_status"`; +} + +function isPgUndefinedTable(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: string }).code === '42P01'; +} + +function isPgUndefinedColumn(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: string }).code === '42703'; +} + +/** Crea cloudrestore_status si la BD existía antes de la migración 002 (idempotente). */ +async function ensureCloudRestoreStatusTable(): Promise { + await pgPool.query('CREATE SCHEMA IF NOT EXISTS a24c'); + await pgPool.query(` + CREATE TABLE IF NOT EXISTS ${qCloudRestoreStatus()} ( + id SERIAL PRIMARY KEY, + instance_key VARCHAR(120) NOT NULL UNIQUE DEFAULT 'default', + input_folder VARCHAR(500) NOT NULL, + host_name VARCHAR(255), + app_version VARCHAR(50), + reported_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); +} + +/** Crea restore_targets y filas Alfa/Omega/Gamma si faltan (migración 001, idempotente). */ +async function ensureRestoreTargetsSchema(): Promise { + await pgPool.query('CREATE SCHEMA IF NOT EXISTS a24c'); + await pgPool.query(` + CREATE TABLE IF NOT EXISTS ${qRestoreTargets()} ( + id SERIAL PRIMARY KEY, + name VARCHAR(120) NOT NULL UNIQUE, + server_ip VARCHAR(255), + sql_username VARCHAR(128), + sql_password_encrypted TEXT, + data_folder VARCHAR(500), + ssh_host VARCHAR(255), + ssh_port INTEGER DEFAULT 22, + ssh_username VARCHAR(128), + ssh_password_encrypted TEXT, + remote_inbox_path VARCHAR(500), + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + await pgPool.query(` + INSERT INTO ${qRestoreTargets()} (name) VALUES ('Alfa'), ('Omega'), ('Gamma') + ON CONFLICT (name) DO NOTHING + `); + for (const stmt of [ + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_host VARCHAR(255)`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_port INTEGER DEFAULT 22`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_username VARCHAR(128)`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_password_encrypted TEXT`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS remote_inbox_path VARCHAR(500)` + ]) { + await pgPool.query(stmt); + } + try { + await pgPool.query(` + ALTER TABLE ${qNodes()} + ADD COLUMN IF NOT EXISTS restore_target_id INTEGER + REFERENCES ${qRestoreTargets()} (id) ON DELETE SET NULL + `); + } catch { + /* database_nodes puede no existir aún en algunos entornos */ + } +} + const ROW_DATABASE_NODE = ` id AS "ID", node_subnode_key AS "NodoSubNodo", @@ -30,7 +115,8 @@ const ROW_DATABASE_NODE = ` branch_name AS "Sucursal", notification_email AS "CorreoNotificacion", server_name AS "ServerName", - database_name AS "BDName" + database_name AS "BDName", + restore_target_id AS "RestoreTargetId" `; const ROW_PORTAL_USER = ` @@ -208,13 +294,20 @@ export async function insertDatabaseNode(row: { serverName: string; bdName: string; activo: number; + restoreTargetId?: number | null; }): Promise { + // server_name se deriva del servidor de restauración asignado (su IP); si el target + // aún no tiene IP configurada, se conserva el serverName recibido (puede ir vacío). await pgPool.query( ` INSERT INTO ${qNodes()} ( node_subnode_key, rfc, legal_name, branch_name, - notification_email, server_name, database_name, is_active - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + notification_email, server_name, database_name, is_active, restore_target_id + ) VALUES ( + $1, $2, $3, $4, $5, + COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $9), $6), + $7, $8, $9 + ) `, [ row.nodoSubNodo, @@ -224,7 +317,8 @@ export async function insertDatabaseNode(row: { row.correo, row.serverName, row.bdName, - row.activo + row.activo, + row.restoreTargetId ?? null ] ); } @@ -240,6 +334,7 @@ export async function updateDatabaseNode( serverName: string; bdName: string; activo: number; + restoreTargetId?: number | null; } ): Promise { await pgPool.query( @@ -250,10 +345,11 @@ export async function updateDatabaseNode( legal_name = $3, branch_name = $4, notification_email = $5, - server_name = $6, + server_name = COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $9), $6), database_name = $7, - is_active = $8 - WHERE id = $9 + is_active = $8, + restore_target_id = $9 + WHERE id = $10 `, [ row.nodoSubNodo, @@ -264,6 +360,7 @@ export async function updateDatabaseNode( row.serverName, row.bdName, row.activo, + row.restoreTargetId ?? null, id ] ); @@ -350,3 +447,333 @@ export async function updatePortalUser( export async function deletePortalUser(id: number): Promise { await pgPool.query(`DELETE FROM ${qUsers()} WHERE id = $1`, [id]); } + +// ============================================================================ +// Servidores de restauración (restore_targets) — integración CloudRestoreAS. +// La contraseña SQL se cifra con AES-256-GCM antes de persistir y solo se +// descifra al entregarla al servicio CloudRestoreAS por el endpoint con token. +// ============================================================================ + +/** Servidor de restauración sin las contraseñas (seguro para la UI). */ +export interface RestoreTarget { + id: number; + name: string; + server_ip: string; + sql_username: string; + data_folder: string; + ssh_host: string; + ssh_port: number; + ssh_username: string; + remote_inbox_path: string; + notes: string | null; +} + +/** Datos de alta/edición. Las contraseñas en texto plano; se cifran aquí. */ +export interface RestoreTargetInput { + name: string; + server_ip: string; + sql_username: string; + sql_password?: string; // opcional en edición: si se omite, no se cambia + data_folder: string; + ssh_host: string; + ssh_port: number; + ssh_username: string; + ssh_password?: string; // opcional en edición: si se omite, no se cambia + remote_inbox_path: string; + notes?: string | null; +} + +const ROW_RESTORE_TARGET = ` + id, name, server_ip, sql_username, data_folder, + ssh_host, ssh_port, ssh_username, remote_inbox_path, notes +`; + +async function queryRestoreTargets(): Promise { + const r = await pgPool.query( + `SELECT ${ROW_RESTORE_TARGET} FROM ${qRestoreTargets()} ORDER BY name` + ); + return r.rows as RestoreTarget[]; +} + +/** Lista los servidores de restauración sin exponer la contraseña (alimenta los radios). */ +export async function listRestoreTargets(): Promise { + try { + return await queryRestoreTargets(); + } catch (e) { + if (!isPgUndefinedTable(e) && !isPgUndefinedColumn(e)) throw e; + await ensureRestoreTargetsSchema(); + return await queryRestoreTargets(); + } +} + +export async function getRestoreTargetById(id: number): Promise { + const r = await pgPool.query( + `SELECT ${ROW_RESTORE_TARGET} FROM ${qRestoreTargets()} WHERE id = $1`, + [id] + ); + return (r.rows[0] as RestoreTarget) ?? null; +} + +/** + * Devuelve el servidor de restauración ASIGNADO a una base de datos, con la contraseña + * descifrada. La base se identifica por database_name o node_subnode_key (lo que CloudRestoreAS + * resuelve del nombre de archivo). Uso exclusivo del endpoint servicio-a-servicio. + * Retorna null si la base no existe o no tiene servidor asignado. + */ +export async function getRestoreTargetForDatabase( + dbNameOrNodo: string, + instanceName?: string | null +): Promise<(RestoreTarget & { sql_password: string; ssh_password: string }) | null> { + const instanceFilter = (instanceName ?? '').trim(); + const r = await pgPool.query( + `SELECT rt.id, rt.name, rt.server_ip, rt.sql_username, rt.data_folder, + rt.ssh_host, rt.ssh_port, rt.ssh_username, rt.remote_inbox_path, rt.notes, + rt.sql_password_encrypted, rt.ssh_password_encrypted + FROM ${qNodes()} dn + JOIN ${qRestoreTargets()} rt ON rt.id = dn.restore_target_id + WHERE (LOWER(TRIM(dn.database_name)) = LOWER(TRIM($1::text)) + OR LOWER(TRIM(dn.node_subnode_key)) = LOWER(TRIM($1::text))) + AND ( + $2::text = '' + OR LOWER(TRIM(rt.name)) = LOWER(TRIM($2::text)) + ) + LIMIT 1`, + [dbNameOrNodo, instanceFilter] + ); + const row = r.rows[0]; + if (!row) return null; + const { sql_password_encrypted, ssh_password_encrypted, ...rest } = row; + // Si el servidor aún no tiene contraseñas configuradas se devuelven vacías: + // el cliente (panel_client) las rechaza por campo requerido y difiere el job. + const sql_password = sql_password_encrypted ? decryptSecret(sql_password_encrypted) : ''; + const ssh_password = ssh_password_encrypted ? decryptSecret(ssh_password_encrypted) : ''; + return { ...(rest as RestoreTarget), sql_password, ssh_password }; +} + +export type RouteAction = 'restore_local' | 'forward'; + +export type RestoreTargetWithRoute = RestoreTarget & { + sql_password: string; + ssh_password: string; + input_folder: string | null; +}; + +/** + * Servidor asignado a una base, con contraseñas descifradas y carpeta de entrada + * reportada por CloudRestoreAS (cloudrestore_status), si existe. + */ +export async function getRestoreTargetWithInputFolderForDatabase( + dbNameOrNodo: string +): Promise { + const r = await pgPool.query( + `SELECT rt.id, rt.name, rt.server_ip, rt.sql_username, rt.data_folder, + rt.ssh_host, rt.ssh_port, rt.ssh_username, rt.remote_inbox_path, rt.notes, + rt.sql_password_encrypted, rt.ssh_password_encrypted, + cs.input_folder + FROM ${qNodes()} dn + JOIN ${qRestoreTargets()} rt ON rt.id = dn.restore_target_id + LEFT JOIN ${qCloudRestoreStatus()} cs + ON LOWER(TRIM(cs.instance_key)) = LOWER(TRIM(rt.name)) + WHERE (LOWER(TRIM(dn.database_name)) = LOWER(TRIM($1::text)) + OR LOWER(TRIM(dn.node_subnode_key)) = LOWER(TRIM($1::text))) + LIMIT 1`, + [dbNameOrNodo] + ); + const row = r.rows[0]; + if (!row) return null; + const { sql_password_encrypted, ssh_password_encrypted, input_folder, ...rest } = row; + const sql_password = sql_password_encrypted ? decryptSecret(sql_password_encrypted) : ''; + const ssh_password = ssh_password_encrypted ? decryptSecret(ssh_password_encrypted) : ''; + const folder = + input_folder && String(input_folder).trim() ? String(input_folder).trim() : null; + return { ...(rest as RestoreTarget), sql_password, ssh_password, input_folder: folder }; +} + +export interface ResolveRouteResult { + action: RouteAction; + db_name: string; + node_key: string; + target: RestoreTargetWithRoute; +} + +/** + * Resuelve nodo/destino desde el nombre del archivo ZIP y determina si el CRA debe + * restaurar localmente o reenviar el ZIP por SFTP. + */ +export async function resolveRouteForFilename( + filename: string, + instanceName?: string | null +): Promise { + const stem = path.parse(filename).name; + const nodes = await listDatabaseNodesActive(); + const matched = matchNodeRowFromBackupStem(stem, nodes); + if (!matched) return null; + + const dbName = String(matched.BDName ?? matched.NodoSubNodo ?? '').trim(); + if (!dbName) return null; + + const target = await getRestoreTargetWithInputFolderForDatabase(dbName); + if (!target) return null; + + const instance = (instanceName ?? '').trim(); + let action: RouteAction; + if (!instance) { + action = 'forward'; + } else if (target.name.trim().toLowerCase() === instance.toLowerCase()) { + action = 'restore_local'; + } else { + action = 'forward'; + } + + return { + action, + db_name: dbName, + node_key: String(matched.NodoSubNodo ?? stem).trim(), + target + }; +} + +export async function createRestoreTarget(input: RestoreTargetInput): Promise { + if (!input.sql_password) { + throw new Error('La contraseña SQL es obligatoria al crear un servidor.'); + } + const cols = [ + 'name', 'server_ip', 'sql_username', 'data_folder', + 'ssh_host', 'ssh_port', 'ssh_username', 'remote_inbox_path', 'notes', + 'sql_password_encrypted' + ]; + const params: unknown[] = [ + input.name, input.server_ip, input.sql_username, input.data_folder, + input.ssh_host, input.ssh_port, input.ssh_username, input.remote_inbox_path, + input.notes ?? null, encryptSecret(input.sql_password) + ]; + if (input.ssh_password) { + cols.push('ssh_password_encrypted'); + params.push(encryptSecret(input.ssh_password)); + } + const placeholders = params.map((_, i) => `$${i + 1}`).join(', '); + const r = await pgPool.query( + `INSERT INTO ${qRestoreTargets()} (${cols.join(', ')}) VALUES (${placeholders}) RETURNING id`, + params + ); + return r.rows[0].id as number; +} + +/** Actualiza un servidor. Cada contraseña (SQL/SSH) solo se reescribe si viene definida. */ +export async function updateRestoreTarget(id: number, input: RestoreTargetInput): Promise { + const sets = [ + 'name = $1', 'server_ip = $2', 'sql_username = $3', 'data_folder = $4', + 'ssh_host = $5', 'ssh_port = $6', 'ssh_username = $7', 'remote_inbox_path = $8', + 'notes = $9', 'updated_at = now()' + ]; + const params: unknown[] = [ + input.name, input.server_ip, input.sql_username, input.data_folder, + input.ssh_host, input.ssh_port, input.ssh_username, input.remote_inbox_path, + input.notes ?? null + ]; + let p = params.length; + if (input.sql_password) { + params.push(encryptSecret(input.sql_password)); + sets.push(`sql_password_encrypted = $${++p}`); + } + if (input.ssh_password) { + params.push(encryptSecret(input.ssh_password)); + sets.push(`ssh_password_encrypted = $${++p}`); + } + params.push(id); + await pgPool.query( + `UPDATE ${qRestoreTargets()} SET ${sets.join(', ')} WHERE id = $${params.length}`, + params + ); +} + +export async function deleteRestoreTarget(id: number): Promise { + await pgPool.query(`DELETE FROM ${qRestoreTargets()} WHERE id = $1`, [id]); +} + +// ============================================================================ +// Estado de CloudRestoreAS (carpeta de entrada reportada por el servicio). +// ============================================================================ + +export interface CloudRestoreStatus { + id: number; + instance_key: string; + input_folder: string; + host_name: string | null; + app_version: string | null; + reported_at: Date; +} + +/** Estados reportados por cada instancia CloudRestoreAS (Alfa/Omega/Gamma). */ +export async function listCloudRestoreStatuses(): Promise { + try { + await ensureCloudRestoreStatusTable(); + const r = await pgPool.query( + ` + SELECT id, instance_key, input_folder, host_name, app_version, reported_at + FROM ${qCloudRestoreStatus()} + ORDER BY instance_key + ` + ); + return r.rows as CloudRestoreStatus[]; + } catch (e) { + if (isPgUndefinedTable(e)) return []; + throw e; + } +} + +/** @deprecated Usar listCloudRestoreStatuses. Mantiene compatibilidad con instancia default. */ +export async function getCloudRestoreStatus(): Promise { + const rows = await listCloudRestoreStatuses(); + return rows.find((r) => r.instance_key === 'default') ?? rows[0] ?? null; +} + +/** UPSERT del estado reportado por CloudRestoreAS (solo vía API servicio). */ +export async function upsertCloudRestoreStatus(row: { + inputFolder: string; + hostName: string | null; + appVersion: string | null; + instanceKey?: string; +}): Promise { + await ensureCloudRestoreStatusTable(); + const key = row.instanceKey ?? 'default'; + await pgPool.query( + ` + INSERT INTO ${qCloudRestoreStatus()} ( + instance_key, input_folder, host_name, app_version, reported_at + ) VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (instance_key) DO UPDATE SET + input_folder = EXCLUDED.input_folder, + host_name = EXCLUDED.host_name, + app_version = EXCLUDED.app_version, + reported_at = now() + `, + [key, row.inputFolder, row.hostName, row.appVersion] + ); +} + +/** Inserta un registro de bitácora reportado por CloudRestoreAS. */ +export async function insertRestoreJobLog(row: { + filename: string; + restoreTargetId: number | null; + dbName: string | null; + status: 'completed' | 'failed' | 'forwarded'; + durationMs: number | null; + errorMessage: string | null; +}): Promise { + await pgPool.query( + ` + INSERT INTO ${qRestoreJobLogs()} ( + filename, restore_target_id, db_name, status, duration_ms, error_message + ) VALUES ($1, $2, $3, $4, $5, $6) + `, + [ + row.filename, + row.restoreTargetId, + row.dbName, + row.status, + row.durationMs, + row.errorMessage + ] + ); +} diff --git a/src/lib/server/crypto-core.test.ts b/src/lib/server/crypto-core.test.ts new file mode 100644 index 0000000..387a8bc --- /dev/null +++ b/src/lib/server/crypto-core.test.ts @@ -0,0 +1,87 @@ +/** + * Pruebas del cifrado AES-256-GCM puro (crypto-core). + * Cubre: round-trip, detección de manipulación (GCM), formato inválido, + * unicidad del IV y validación de clave/longitud. + */ +import { describe, it, expect } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { + encryptWithKey, + decryptWithKey, + isEncrypted, + decodeKey, + KEY_LENGTH, + VERSION_PREFIX +} from './crypto-core'; + +const KEY = randomBytes(KEY_LENGTH); + +describe('crypto-core AES-256-GCM', () => { + it('round-trip: descifrar devuelve el texto original', () => { + const plaintext = 'Soluciones01!'; + const envelope = encryptWithKey(plaintext, KEY); + expect(decryptWithKey(envelope, KEY)).toBe(plaintext); + }); + + it('produce sobres con prefijo de versión gcm:', () => { + const envelope = encryptWithKey('secreto', KEY); + expect(envelope.startsWith(`${VERSION_PREFIX}:`)).toBe(true); + expect(isEncrypted(envelope)).toBe(true); + expect(isEncrypted('texto-plano')).toBe(false); + expect(isEncrypted(null)).toBe(false); + }); + + it('usa IV aleatorio: dos cifrados del mismo texto difieren', () => { + const a = encryptWithKey('mismo', KEY); + const b = encryptWithKey('mismo', KEY); + expect(a).not.toBe(b); + // pero ambos descifran al mismo valor + expect(decryptWithKey(a, KEY)).toBe(decryptWithKey(b, KEY)); + }); + + it('detecta manipulación del ciphertext (auth tag GCM)', () => { + const envelope = encryptWithKey('integridad', KEY); + const parts = envelope.split(':'); + // Alterar un byte del ciphertext + const tampered = Buffer.from(parts[3], 'base64'); + tampered[0] = tampered[0] ^ 0xff; + parts[3] = tampered.toString('base64'); + expect(() => decryptWithKey(parts.join(':'), KEY)).toThrow(); + }); + + it('falla al descifrar con clave incorrecta', () => { + const envelope = encryptWithKey('secreto', KEY); + const otherKey = randomBytes(KEY_LENGTH); + expect(() => decryptWithKey(envelope, otherKey)).toThrow(); + }); + + it('rechaza formato de sobre inválido', () => { + expect(() => decryptWithKey('no-es-un-sobre', KEY)).toThrow(/Formato/); + expect(() => decryptWithKey('aes:1:2:3', KEY)).toThrow(/Formato/); + }); + + it('rechaza claves con longitud incorrecta', () => { + expect(() => encryptWithKey('x', randomBytes(16))).toThrow(/32 bytes/); + }); + + describe('decodeKey', () => { + it('decodifica clave base64 de 32 bytes', () => { + const b64 = randomBytes(KEY_LENGTH).toString('base64'); + expect(decodeKey(b64).length).toBe(KEY_LENGTH); + }); + + it('decodifica clave hex de 32 bytes', () => { + const hex = randomBytes(KEY_LENGTH).toString('hex'); + expect(decodeKey(hex).length).toBe(KEY_LENGTH); + }); + + it('lanza si la clave está ausente', () => { + expect(() => decodeKey(undefined)).toThrow(/ENCRYPTION_KEY/); + expect(() => decodeKey(' ')).toThrow(/ENCRYPTION_KEY/); + }); + + it('lanza si la clave no decodifica a 32 bytes', () => { + expect(() => decodeKey('demasiado-corta')).toThrow(/32 bytes/); + }); + }); +}); diff --git a/src/lib/server/crypto-core.ts b/src/lib/server/crypto-core.ts new file mode 100644 index 0000000..102b99c --- /dev/null +++ b/src/lib/server/crypto-core.ts @@ -0,0 +1,86 @@ +/** + * Lógica pura de cifrado AES-256-GCM (estándar Aduanasoft §4). Sin dependencias de + * SvelteKit ni de entorno, para ser testeable de forma aislada. La clave de 256 bits + * se inyecta como parámetro; quien la resuelve desde el entorno es `crypto.ts`. + * + * Formato del sobre: `gcm:::`. + */ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; + +export const ALGORITHM = 'aes-256-gcm'; +export const IV_LENGTH = 12; // 96 bits, recomendado para GCM +export const KEY_LENGTH = 32; // 256 bits +export const VERSION_PREFIX = 'gcm'; + +/** Cifra `plaintext` con la clave dada y devuelve el sobre versionado. */ +export function encryptWithKey(plaintext: string, key: Buffer): string { + if (key.length !== KEY_LENGTH) { + throw new Error(`La clave debe ser de ${KEY_LENGTH} bytes; se recibieron ${key.length}.`); + } + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return [ + VERSION_PREFIX, + iv.toString('base64'), + authTag.toString('base64'), + ciphertext.toString('base64') + ].join(':'); +} + +/** + * Descifra un sobre producido por `encryptWithKey`. Lanza si el formato es inválido + * o si la autenticación GCM falla (dato manipulado o clave incorrecta). + */ +export function decryptWithKey(payload: string, key: Buffer): string { + if (key.length !== KEY_LENGTH) { + throw new Error(`La clave debe ser de ${KEY_LENGTH} bytes; se recibieron ${key.length}.`); + } + const parts = String(payload ?? '').split(':'); + if (parts.length !== 4 || parts[0] !== VERSION_PREFIX) { + throw new Error('Formato de texto cifrado inválido (se esperaba gcm:iv:tag:ciphertext).'); + } + + const iv = Buffer.from(parts[1], 'base64'); + const authTag = Buffer.from(parts[2], 'base64'); + const ciphertext = Buffer.from(parts[3], 'base64'); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); +} + +/** Indica si un valor ya está cifrado con este esquema (para detectar datos legados en plano). */ +export function isEncrypted(value: string | null | undefined): boolean { + return typeof value === 'string' && value.startsWith(`${VERSION_PREFIX}:`); +} + +/** + * Decodifica una clave de 32 bytes desde una cadena base64 o hex. + * Lanza con mensaje claro si no decodifica a la longitud esperada. + */ +export function decodeKey(raw: string | undefined): Buffer { + if (!raw || !raw.trim()) { + throw new Error( + 'ENCRYPTION_KEY no está configurada. Genera una con: ' + + 'node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))"' + ); + } + const trimmed = raw.trim(); + + let key = Buffer.from(trimmed, 'base64'); + if (key.length !== KEY_LENGTH) { + key = Buffer.from(trimmed, 'hex'); + } + if (key.length !== KEY_LENGTH) { + throw new Error( + `ENCRYPTION_KEY debe decodificar a ${KEY_LENGTH} bytes (256 bits) en base64 o hex; ` + + `se obtuvieron ${key.length} bytes.` + ); + } + return key; +} diff --git a/src/lib/server/crypto.ts b/src/lib/server/crypto.ts new file mode 100644 index 0000000..fe1e755 --- /dev/null +++ b/src/lib/server/crypto.ts @@ -0,0 +1,26 @@ +/** + * 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. + * + * 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. + */ +import { env } from '$env/dynamic/private'; +import { encryptWithKey, decryptWithKey, decodeKey, isEncrypted } from './crypto-core'; + +function getKey(): Buffer { + return decodeKey(env.ENCRYPTION_KEY); +} + +/** Cifra un secreto en texto plano. Devuelve el sobre versionado listo para persistir. */ +export function encryptSecret(plaintext: string): string { + return encryptWithKey(plaintext, getKey()); +} + +/** Descifra un sobre producido por `encryptSecret`. */ +export function decryptSecret(payload: string): string { + return decryptWithKey(payload, getKey()); +} + +export { isEncrypted }; diff --git a/src/lib/server/logger.ts b/src/lib/server/logger.ts new file mode 100644 index 0000000..ab901d6 --- /dev/null +++ b/src/lib/server/logger.ts @@ -0,0 +1,36 @@ +/** + * Logging estructurado en JSON (estándar Aduanasoft §13). Escribe a stdout/stderr; + * el orquestador (Docker/K8s) lo recolecta. Evita console.log de depuración suelto. + */ +const SERVICE = 'panel-bases-anexo24'; + +type LogLevel = 'info' | 'warn' | 'error'; + +interface LogFields { + trace_id?: string; + message: string; + context?: Record; +} + +function emit(level: LogLevel, fields: LogFields): void { + const entry = { + timestamp: new Date().toISOString(), + level, + service: SERVICE, + trace_id: fields.trace_id ?? null, + message: fields.message, + context: fields.context ?? {} + }; + const line = JSON.stringify(entry); + if (level === 'error') { + console.error(line); + } else { + console.log(line); + } +} + +export const logger = { + info: (fields: LogFields) => emit('info', fields), + warn: (fields: LogFields) => emit('warn', fields), + error: (fields: LogFields) => emit('error', fields) +}; diff --git a/src/lib/server/service-auth.ts b/src/lib/server/service-auth.ts new file mode 100644 index 0000000..ca91933 --- /dev/null +++ b/src/lib/server/service-auth.ts @@ -0,0 +1,40 @@ +/** + * Autenticación servicio-a-servicio por token Bearer para los endpoints que + * consume CloudRestoreAS. Separado del JWT de usuarios (cookie) porque el cliente + * es una máquina, no un navegador. + * + * NOTA DE SEGURIDAD: estos endpoints entregan credenciales SQL. En producción + * deben servirse solo sobre transporte cifrado (TLS/SSH/VPN) — ver plan, G5. + */ +import { timingSafeEqual } from 'node:crypto'; +import { env } from '$env/dynamic/private'; + +/** + * Valida el header Authorization: Bearer contra CLOUDRESTORE_API_TOKEN. + * Devuelve null si es válido, o el código HTTP a responder (401/500) si no. + * Comparación en tiempo constante para no filtrar el token por timing. + */ +export function checkServiceToken(request: Request): { ok: true } | { ok: false; status: 401 | 500 } { + const expected = env.CLOUDRESTORE_API_TOKEN; + if (!expected || !expected.trim()) { + // Sin token configurado no se puede autenticar de forma segura: se rechaza. + return { ok: false, status: 500 }; + } + + const header = request.headers.get('authorization') ?? ''; + const match = header.match(/^Bearer\s+(.+)$/i); + if (!match) { + return { ok: false, status: 401 }; + } + const provided = match[1].trim(); + + const expectedBuf = Buffer.from(expected.trim(), 'utf8'); + const providedBuf = Buffer.from(provided, 'utf8'); + if (expectedBuf.length !== providedBuf.length) { + return { ok: false, status: 401 }; + } + if (!timingSafeEqual(expectedBuf, providedBuf)) { + return { ok: false, status: 401 }; + } + return { ok: true }; +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index d19dcb5..5ad90ee 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -21,7 +21,8 @@ import { deleteDatabaseNode, insertPortalUser, updatePortalUser, - deletePortalUser + deletePortalUser, + listRestoreTargets } from '$lib/server/controldesk-pg'; import { listAdditionalEmails, @@ -45,6 +46,24 @@ function parseActivoField(formData: FormData): number { return v === 'true' || v === '1' ? 1 : 0; } +/** Lee el id del servidor de restauración elegido (radio); null si no se asignó. */ +function parseRestoreTargetId(formData: FormData): number | null { + const v = formData.get('restore_target_id'); + if (v === null || v === undefined || String(v).trim() === '') return null; + const n = Number(v); + return Number.isInteger(n) && n > 0 ? n : null; +} + +/** Verifica que el solicitante sea administrador (autorización en backend, no solo UI). */ +async function isAdmin(cookies: import('@sveltejs/kit').Cookies): Promise { + const token = cookies.get('session_token'); + if (!token) return false; + const session = verifyToken(token); + if (!session) return false; + const user = await getUserById(session.userId); + return !!user?.es_admin; +} + const DEFAULT_DATABASE_SERVER = '104.192.7.152'; function portalUserUniqueViolationMessage(err: unknown): string | null { @@ -99,6 +118,7 @@ export const load: PageServerLoad = async ({ cookies }) => { let usuariosList: any[] = []; let additionalEmails: any[] = []; let authorityEmails: any[] = []; + let restoreTargets: any[] = []; let restoreHistory: Record = {}; let effectivenessByDb: Record = {}; @@ -141,6 +161,7 @@ export const load: PageServerLoad = async ({ cookies }) => { usuariosList = await listPortalUsers(); additionalEmails = await listAdditionalEmails(); authorityEmails = await listAuthorityEmails(); + restoreTargets = await listRestoreTargets(); controlDeskOk = true; } catch (e: any) { console.error('Error loading ControlDesk (PostgreSQL):', e); @@ -325,6 +346,7 @@ export const load: PageServerLoad = async ({ cookies }) => { usuariosList, additionalEmails, authorityEmails, + restoreTargets, restoreHistory, effectivenessByDb, @@ -383,20 +405,26 @@ export const actions: Actions = { } }, - createDatabase: async ({ request }) => { + createDatabase: async ({ request, cookies }) => { try { + if (!(await isAdmin(cookies))) { + return { success: false, message: 'No autorizado' }; + } const formData = await request.formData(); const nodoSubNodo = String(formData.get('NodoSubNodo') || '').trim(); const rfc = String(formData.get('RFC') || '').trim(); const nombre = String(formData.get('Nombre') || '').trim(); const sucursal = String(formData.get('Sucursal') || '').trim(); const correo = String(formData.get('CorreoNotificacion') || '').trim(); + // El servidor de la base se elige por radio (restore_target_id); server_name + // se deriva de su IP. Se conserva un fallback para no dejar server_name vacío. + const restoreTargetId = parseRestoreTargetId(formData); const serverName = String(formData.get('ServerName') || '').trim() || DEFAULT_DATABASE_SERVER; const bdName = nodoSubNodo; const activo = parseActivoField(formData); - if (!nodoSubNodo || !rfc || !nombre || !correo || !serverName) { + if (!nodoSubNodo || !rfc || !nombre || !correo) { return { success: false, message: 'Faltan campos requeridos' }; } @@ -408,7 +436,8 @@ export const actions: Actions = { correo, serverName, bdName, - activo + activo, + restoreTargetId }); return { success: true }; @@ -418,8 +447,11 @@ export const actions: Actions = { } }, - updateDatabase: async ({ request }) => { + updateDatabase: async ({ request, cookies }) => { try { + if (!(await isAdmin(cookies))) { + return { success: false, message: 'No autorizado' }; + } const formData = await request.formData(); const id = Number(formData.get('ID')); const nodoSubNodo = String(formData.get('NodoSubNodo') || '').trim(); @@ -427,12 +459,13 @@ export const actions: Actions = { const nombre = String(formData.get('Nombre') || '').trim(); const sucursal = String(formData.get('Sucursal') || '').trim(); const correo = String(formData.get('CorreoNotificacion') || '').trim(); + const restoreTargetId = parseRestoreTargetId(formData); const serverName = String(formData.get('ServerName') || '').trim() || DEFAULT_DATABASE_SERVER; const bdName = nodoSubNodo; const activo = parseActivoField(formData); - if (!id || !nodoSubNodo || !rfc || !nombre || !correo || !serverName) { + if (!id || !nodoSubNodo || !rfc || !nombre || !correo) { return { success: false, message: 'Faltan campos requeridos' }; } @@ -444,7 +477,8 @@ export const actions: Actions = { correo, serverName, bdName, - activo + activo, + restoreTargetId }); return { success: true }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ffdf793..4e97b22 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,6 +1,7 @@ + +
+
+
+

Servidores de Restauración

+

+ Configura IP, credenciales y rutas de cada servidor. La asignación de cada base a + un servidor se hace en Gestión de Bases de Datos (un radio por base). +

+
+ +
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + {#if form?.success} +
+ Operación realizada correctamente. +
+ {/if} + {#if data.dbWarning} +
+ {data.dbWarning} +
+ {/if} + +
+

CloudRestoreAS por servidor

+

+ Cada servidor de restauración puede tener su propia instalación de CloudRestoreAS. + La carpeta de entrada se configura en cada equipo; el panel solo la muestra. +

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

+ Crea un servidor con + Nuevo servidor. +

+ {:else} +
+ {#each targets as t (t.id)} + {@const cra = statusForTarget(t.name)} +
+
+ {t.name} + {#if cra && !isStaleStatus(cra)} + + Reportada + + {:else} + + Sin reportar + + {/if} +
+
+
+
Carpeta entrada
+
+ {cra?.input_folder ?? '—'} +
+
+
+
Equipo
+
{cra?.host_name ?? '—'}
+
+
+
Última actualización
+
{formatReportedAt(cra?.reported_at)}
+
+
+
+ {/each} +
+ {/if} +
+ +
+ + + + + + + + + + + + {#each targets as t (t.id)} + {@const configured = !!(t.server_ip && t.sql_username)} + + + + + + + + {:else} + + + + {/each} + +
NombreServidor SQLUsuario SQLHost SSHAcciones
+ {t.name} + {#if !configured} + + sin configurar + + {/if} + {t.server_ip ?? '—'}{t.sql_username ?? '—'} + {t.ssh_host ? `${t.ssh_host}:${t.ssh_port ?? 22}` : '—'} + +
+ +
+ + +
+
+
+ No hay servidores de restauración registrados. +
+
+
+ +{#if showModal} +
+
+

+ {editing ? 'Editar servidor' : 'Nuevo servidor'} +

+ +
{ + return async ({ result, update }) => { + await update(); + if (result.type === 'success') closeModal(); + }; + }} + class="space-y-3" + > + {#if editing} + + {/if} + +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+

+ Transferencia SFTP/SSH (el .bak se sube al servidor externo) +

+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+
+{/if} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b2f341d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +// Configuración mínima de Vitest para pruebas unitarias de lógica pura del servidor +// (cifrado, helpers). Las pruebas no dependen de SvelteKit ni del entorno del navegador. +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'] + } +}); -- 2.49.1 From 310e0130c2cf0fd10341dcf08e5d2cf2cf061deb Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 5 Jun 2026 12:57:42 -0600 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20eliminar=20migrations=20SQL=20dupl?= =?UTF-8?q?icadas=20=E2=80=94=20Alembic=20es=20fuente=20de=20verdad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit database/migrations/001_restore_targets y 002_cloudrestore_status duplican exactamente la migración c4d5e6f7a8b9 de Alembic en a24c. Ningún script las referenciaba; se aplican desde a24c con alembic upgrade head. Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/001_restore_targets.down.sql | 8 --- .../migrations/001_restore_targets.up.sql | 49 ------------------- .../002_cloudrestore_status.down.sql | 3 -- .../migrations/002_cloudrestore_status.up.sql | 17 ------- 4 files changed, 77 deletions(-) delete mode 100644 database/migrations/001_restore_targets.down.sql delete mode 100644 database/migrations/001_restore_targets.up.sql delete mode 100644 database/migrations/002_cloudrestore_status.down.sql delete mode 100644 database/migrations/002_cloudrestore_status.up.sql diff --git a/database/migrations/001_restore_targets.down.sql b/database/migrations/001_restore_targets.down.sql deleted file mode 100644 index 83c2449..0000000 --- a/database/migrations/001_restore_targets.down.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Migración 001 (DOWN): revierte la integración CloudRestoreAS. --- Se quita primero la columna de asignación y la tabla dependiente. - -ALTER TABLE a24c.database_nodes DROP COLUMN IF EXISTS restore_target_id; - -DROP TABLE IF EXISTS a24c.restore_job_logs; - -DROP TABLE IF EXISTS a24c.restore_targets; diff --git a/database/migrations/001_restore_targets.up.sql b/database/migrations/001_restore_targets.up.sql deleted file mode 100644 index a3c458d..0000000 --- a/database/migrations/001_restore_targets.up.sql +++ /dev/null @@ -1,49 +0,0 @@ --- Migración 001 (UP): integración CloudRestoreAS. --- Servidores de restauración (Alfa/Omega/Gamma) + asignación por base de datos --- (database_nodes.restore_target_id) + bitácora de jobs. Idempotente. --- Rollback en 001_restore_targets.down.sql. - -CREATE SCHEMA IF NOT EXISTS a24c; - -CREATE TABLE IF NOT EXISTS a24c.restore_targets ( - id SERIAL PRIMARY KEY, - name VARCHAR(120) NOT NULL UNIQUE, - server_ip VARCHAR(255), - sql_username VARCHAR(128), - sql_password_encrypted TEXT, - data_folder VARCHAR(500), - ssh_host VARCHAR(255), - ssh_port INTEGER DEFAULT 22, - ssh_username VARCHAR(128), - ssh_password_encrypted TEXT, - remote_inbox_path VARCHAR(500), - notes TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -INSERT INTO a24c.restore_targets (name) VALUES ('Alfa'), ('Omega'), ('Gamma') -ON CONFLICT (name) DO NOTHING; - --- Columna de asignación en database_nodes (tabla aprovisionada por el backend a24c). -ALTER TABLE a24c.database_nodes - ADD COLUMN IF NOT EXISTS restore_target_id INTEGER - REFERENCES a24c.restore_targets (id) ON DELETE SET NULL; - -CREATE TABLE IF NOT EXISTS a24c.restore_job_logs ( - id SERIAL PRIMARY KEY, - filename VARCHAR(500) NOT NULL, - restore_target_id INTEGER REFERENCES a24c.restore_targets (id) ON DELETE SET NULL, - db_name VARCHAR(255), - status VARCHAR(20) NOT NULL CHECK (status IN ('completed', 'failed', 'forwarded')), - duration_ms INTEGER, - error_message TEXT, - restored_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_target - ON a24c.restore_job_logs (restore_target_id); -CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_restored_at - ON a24c.restore_job_logs (restored_at); -CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_status - ON a24c.restore_job_logs (status); diff --git a/database/migrations/002_cloudrestore_status.down.sql b/database/migrations/002_cloudrestore_status.down.sql deleted file mode 100644 index 9865934..0000000 --- a/database/migrations/002_cloudrestore_status.down.sql +++ /dev/null @@ -1,3 +0,0 @@ -SET search_path TO a24c, public; - -DROP TABLE IF EXISTS a24c.cloudrestore_status; diff --git a/database/migrations/002_cloudrestore_status.up.sql b/database/migrations/002_cloudrestore_status.up.sql deleted file mode 100644 index 096fc9f..0000000 --- a/database/migrations/002_cloudrestore_status.up.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Estado reportado por CloudRestoreAS (carpeta de entrada, host, versión). --- Solo CloudRestoreAS escribe vía POST /api/restore/instance-config; el panel solo consulta. --- Rollback en 002_cloudrestore_status.down.sql. - -SET search_path TO a24c, public; - -CREATE TABLE IF NOT EXISTS a24c.cloudrestore_status ( - id SERIAL PRIMARY KEY, - instance_key VARCHAR(120) NOT NULL UNIQUE DEFAULT 'default', - input_folder VARCHAR(500) NOT NULL, - host_name VARCHAR(255), - app_version VARCHAR(50), - reported_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -COMMENT ON TABLE a24c.cloudrestore_status IS - 'Carpeta de entrada vigente reportada por CloudRestoreAS. Solo lectura en el panel.'; -- 2.49.1 From 12cf0e092dd009239c7788340a185e893774a711 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 5 Jun 2026 12:59:13 -0600 Subject: [PATCH 3/3] feature/correcciones-migraciones --- .gitignore | 8 ++++++++ scripts/init-database.js | 16 +++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index b0b298e..8d4b112 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,11 @@ docker-compose.override.yml # Respaldos sintéticos para pruebas locales local-backups/ + +# Certificados y llaves privadas — nunca en git +certs/ +*.key +*.pem +*.crt +*.p12 +*.pfx diff --git a/scripts/init-database.js b/scripts/init-database.js index 331ce27..e48f46b 100644 --- a/scripts/init-database.js +++ b/scripts/init-database.js @@ -7,13 +7,19 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const pgPassword = process.env.DB_POSTGRES_PASS; +if (!pgPassword) { + console.error('❌ Falta variable de entorno DB_POSTGRES_PASS. Copia .env.example → .env y configura el valor.'); + process.exit(1); +} + // Configuración de PostgreSQL (Docker local) const pool = new Pool({ - host: 'localhost', - port: 5432, - database: 'CONTROLDESK', - user: 'postgres', - password: 'Control.', + host: process.env.DB_POSTGRES_HOST || 'localhost', + port: Number(process.env.DB_POSTGRES_PORT) || 5432, + database: process.env.DB_POSTGRES_DB || 'CONTROLDESK', + user: process.env.DB_POSTGRES_USER || 'postgres', + password: pgPassword, max: 1 }); -- 2.49.1