diff --git a/AGENTS.md b/AGENTS.md index 981b05f..f171fbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): - Calendario y Gantt de solo lectura - Comentarios y adjuntos - Notificaciones in-app +- Equipos (inventario TI alimentado por agentes GLPI — ver `docs/ARQUITECTURA-INVENTARIO.md`) - Configuración (catálogos: estados, prioridades, tipos de proyecto) ## Restricciones tecnicas @@ -125,6 +126,8 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): - SQL siempre parametrizado. Uploads: validar MIME/tamaño, ruta de archivo generada por el servidor. - `forgot-password` responde SIEMPRE genérico (exista o no el correo); el envío de correo nunca debe poder tumbar ni cambiar la respuesta de un endpoint (`sendMail` atrapa sus propios errores). +- Ingesta de Equipos (`POST /api/inventory`): sin JWT, autenticada por token de instalación + (`inventory_tokens`, solo hash); cuerpo crudo con límite de tamaño e inflado. - Sin SMTP configurado, el sistema degrada a modo dev: los correos de verificación/recuperación se loguean en la consola del backend en vez de enviarse (nunca rompe el flujo). @@ -160,7 +163,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): - `README.md`, `SPEC.md`, `BASE_DE_DATOS.md`, `CONFIGURACION.md`, `API.md`, `VERSIONADO.md` - `PRODUCCION.md`, `DEPLOY_WEB.md` (topología y flujo de deploy — arquetipo B, en producción) -- `docs/UI-UX-WINDOWS.md`, `docs/SEGURIDAD-Y-CALIDAD.md` +- `docs/UI-UX-WINDOWS.md`, `docs/SEGURIDAD-Y-CALIDAD.md`, `docs/ARQUITECTURA-INVENTARIO.md` - `ESTADO.md` (handoff — leer siempre al retomar el proyecto) ## Pendientes para quedar listo diff --git a/API.md b/API.md index 1e0d1cc..0b1d4c9 100644 --- a/API.md +++ b/API.md @@ -119,6 +119,20 @@ - Siempre privadas: una nota de otro usuario responde `404` (no `403`, para no revelar que existe). Sin restricción de rol — cualquier autenticado tiene las suyas. +### Equipos (`/api/devices`) — superadmin / admin / it_manager (tokens: solo superadmin / admin) +- `GET /api/devices?q=&kind=&status=&stale=1&diskFull=1` · `GET /api/devices/:id` (con hardware, + red, software e historial) · `PUT /api/devices/:id` — `{ status, assignedUserId, departmentId, notes }`. +- `GET /api/devices/dashboard` — KPIs y top (SO, tipo, software). `GET /api/devices/software` · + `GET /api/devices/software/devices?name=` — software transversal. +- `GET /api/devices/:id/raw` — último inventario crudo del agente. +- `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta + (el token en claro solo se devuelve en el `POST`). + +### Ingesta del agente GLPI (sin JWT; token de ingesta) +- `POST /api/inventory` (alias `POST /front/inventory.php`) — cuerpo JSON/XML, opcionalmente + comprimido (gzip/zlib). Auth: password Basic / `Bearer` / `X-Ingest-Token` / `?token=`. + `401` sin token válido, `400` formato inválido. Responde `{"status":"ok"}` (o `` en XML). + ### Notificaciones (`/api/notifications`) - `GET /api/notifications` · `PATCH /api/notifications/:id/read` · `PATCH /api/notifications/read-all` diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 7c0a86e..2b68435 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -42,6 +42,10 @@ | `notes` | apuntes rápidos personales, menú propio "Notas"; editable solo por el dueño | | `note_shares` | con quién está compartida (solo lectura) cada nota — `note_id, user_id` | | `vault_entries` | repositorio de credenciales privado por usuario (menú "Contraseñas"), contraseña/notas cifradas | +| `devices` | inventario de equipos (menú "Equipos"), upsert por `deviceid` del agente GLPI; baja lógica con `status` | +| `device_cpus` / `device_memories` / `device_disks` / `device_volumes` / `device_networks` / `device_monitors` / `device_software` | componentes de cada equipo, reemplazados completos en cada reporte | +| `device_changes` | diff entre reportes (RAM, discos, software instalado/desinstalado) | +| `inventory_tokens` | tokens de ingesta del agente (solo hash sha256) | ## Relaciones clave @@ -202,3 +206,9 @@ Método: archivos SQL idempotentes en `backend/database/migrations/00X-nombre.sq (`CREATE TABLE IF NOT EXISTS` / `information_schema` guards), aplicados con `node src/scripts/migrate-db.js`. Siempre backup (`mysqldump --single-transaction`) antes de migrar en producción. + +- Equipos (desde v2.15.0, migración `023_devices.sql`): `devices.deviceid` UNIQUE; `assigned_user_id` + y `department_id` son datos manuales (el agente nunca los toca). Hijos con `ON DELETE CASCADE`. + `device_software.is_component` (migración `024`) marca actualizaciones/runtimes/drivers/paquetes del + sistema (según `system_category` del agente + nombre, ver `normalizer.js#isComponent`); las vistas de + software lo ocultan salvo que se marque la casilla. `inventory_tokens` guarda solo el hash; el token en claro se muestra una vez al crearlo. diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e08a3..2e7bf1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog - project.linkedcorp.net +## [2.15.0] - 2026-10-19 +### Added +- Menú **Equipos**: inventario de TI alimentado por el agente GLPI oficial (sin servidor GLPI). + Ingesta `POST /api/inventory` (JSON/XML, gzip/zlib) con token de instalación, upsert por + `deviceid`, historial de cambios, KPIs, software transversal y pestaña Agentes para generar + tokens. Edición manual de estado, usuario asignado, departamento y notas. +- Migración `023_devices.sql` (devices + componentes, device_changes, inventory_tokens). +- Equipos → Software (general y por equipo): casilla "Mostrar actualizaciones, drivers y componentes + del sistema", desmarcada por defecto (solo aplicaciones). Migración `024` agrega `category` e + `is_component` a `device_software`; se decide por `system_category` del agente y por nombre. +- Dependencia backend: `fast-xml-parser`. Diseño traído de linkedtrack (que sigue aparte) en + `docs/ARQUITECTURA-INVENTARIO.md`. + ## [2.14.0] - 2026-10-14 ### Added - Contraseñas: "Compartir grupo" en el menú de un grupo — comparte de una vez todas sus diff --git a/CONFIGURACION.md b/CONFIGURACION.md index f798da7..8e02663 100644 --- a/CONFIGURACION.md +++ b/CONFIGURACION.md @@ -89,3 +89,8 @@ export default defineConfig({ - Backend responde en `4019` (`curl http://localhost:4019/health`). - Conexión a base válida (`node -e "require('./src/config/db')" ` o el log de arranque del servidor). - `.env.example` alineado con el código (nuevas variables se agregan también ahí). + +## Equipos (inventario) + +- `INVENTORY_MAX_BYTES` (opcional, default 15 MB): tope del cuerpo de `POST /api/inventory`. +- Los inventarios crudos se guardan en `STORAGE_ROOT/inventories/` (un archivo por equipo). diff --git a/ESTADO.md b/ESTADO.md index 349a9f1..359584c 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -1,6 +1,6 @@ # ESTADO — project.linkedcorp.net -Ultima actualizacion: 2026-09-06 +Ultima actualizacion: 2026-10-19 (v2.15.0 en desarrollo, sin desplegar) Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo + autorregistro/aprobacion/SMTP. @@ -20,6 +20,17 @@ autorregistro/aprobacion/SMTP. ## Hecho y verificado +### Menú Equipos (v2.15.0, 2026-10-19) — EN DESARROLLO, NO DESPLEGADO + +- Inventario de TI con agente GLPI oficial; diseño traído de linkedtrack (que sigue aparte, sin + tocar): `docs/ARQUITECTURA-INVENTARIO.md`. Backend `modules/devices/` (ingest, normalizer, + repository, service, routes), migración `023` aplicada en dev, frontend `pages/Equipos/`. +- Verificado con inventarios sintéticos JSON y XML (alta, actualización, diff, 401 sin token, + handshake `contact`). **Pendiente: probar con un agente GLPI real** (ver "Pendientes" del doc de + arquitectura) y ajustar `normalizer.js`/handshake según el JSON real. +- Antes de producción: migración 023 en `.32`, `npm install` (fast-xml-parser), confirmar que NPM + deja pasar `POST /api/inventory` desde la red de los equipos y subir versión/deploy. + ### Fase 1 — MVP en desarrollo (2026-09-03) - Backend completo: auth (JWT + rate limit), users, departments, catalogs, projects (+members), diff --git a/README.md b/README.md index dc0ef4d..bcfe65b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ project.linkedcorp.net/ - Proyectos, Actividades y Tareas - Dashboard, Calendario, Gantt (solo lectura) - Comentarios, adjuntos, notificaciones in-app +- Equipos (inventario de TI con agentes GLPI) - Configuración (catálogos) ## Arranque rapido @@ -76,5 +77,6 @@ la misma red. - `API.md` — contrato de la API. - `BASE_DE_DATOS.md` — modelo de datos. - `CONFIGURACION.md` — variables de entorno y setup local. +- `docs/ARQUITECTURA-INVENTARIO.md` — módulo Equipos (agente GLPI + colector). - `docs/UI-UX-WINDOWS.md` — identidad visual del proyecto (Fluent/Windows 11). - `ESTADO.md` — punto de retoma del proyecto (handoff). diff --git a/SPEC.md b/SPEC.md index 9253a60..667e71f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -77,7 +77,19 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa - In-app (campana con contador): asignación de actividad, actividad vencida/por vencer (job diario), cambio de estado relevante. -### 11. Configuración +### 11. Equipos (inventario TI, desde v2.15.0) +- Menú "Equipos" visible para `superadmin`, `admin` y `it_manager`. Inventario automático de + hardware y software alimentado por el **agente GLPI oficial** instalado en cada equipo (sin + servidor GLPI). Diseño en `docs/ARQUITECTURA-INVENTARIO.md`. +- Pestañas: Equipos (KPIs + listado + detalle General/Hardware/Red/Software/Historial), + Software (transversal: en cuántos equipos está instalado) y Agentes (tokens de ingesta, + solo `superadmin`/`admin`). +- Edición manual solo de: estado (activo/de baja), usuario asignado, departamento y notas; el + resto lo manda el agente. Historial de cambios entre reportes. +- Fuera de este módulo (fase 2): tickets/helpdesk, despliegue remoto de software, panel de + impresoras con tóner, export CSV. + +### 12. Configuración - Catálogos editables por el Administrador: estados de proyecto, estados de actividad, prioridades, tipos de proyecto, departamentos. Vienen con datos semilla — no requieren configuración inicial. - Preferencias de interfaz: idioma es-EC/en-US, tema claro/oscuro, apariencia de botones. @@ -115,7 +127,7 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa - Reportes exportables (PDF/Excel/CSV) y vista ejecutiva separada. - Permisos configurables por módulo × acción desde la UI. - Notificaciones por email/WhatsApp. -- Equipos de trabajo como entidad propia multi-proyecto (hoy: miembros por proyecto). +- Equipos de trabajo (personas) como entidad propia multi-proyecto (hoy: miembros por proyecto). ## Estado para produccion diff --git a/VERSION b/VERSION index edcfe40..68e69e4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.0 +2.15.0 diff --git a/backend/database/migrations/023_devices.sql b/backend/database/migrations/023_devices.sql new file mode 100644 index 0000000..c57ff10 --- /dev/null +++ b/backend/database/migrations/023_devices.sql @@ -0,0 +1,155 @@ +-- Modulo Equipos (inventario alimentado por el agente GLPI). Idempotente: CREATE TABLE IF NOT EXISTS. +-- Un equipo = una fila en `devices`, upsert por `deviceid` (identificador estable que genera el +-- agente; el hostname cambia). Los componentes se reemplazan completos en cada reporte. +SET NAMES utf8mb4; + +-- Tokens que autentican al agente en la ingesta. Se guarda el HASH (sha256), nunca el token. +CREATE TABLE IF NOT EXISTS inventory_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + label VARCHAR(120) NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + is_active TINYINT(1) NOT NULL DEFAULT 1, + last_used_at DATETIME NULL, + created_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_inventory_tokens_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS devices ( + id INT AUTO_INCREMENT PRIMARY KEY, + deviceid VARCHAR(255) NOT NULL UNIQUE, + hostname VARCHAR(190) NULL, + kind ENUM('desktop','laptop','server','virtual','printer','network','other') NOT NULL DEFAULT 'other', + manufacturer VARCHAR(160) NULL, + model VARCHAR(190) NULL, + serial VARCHAR(160) NULL, + os_name VARCHAR(190) NULL, + os_version VARCHAR(120) NULL, + os_arch VARCHAR(40) NULL, + domain VARCHAR(190) NULL, + last_user VARCHAR(190) NULL, + ip_main VARCHAR(64) NULL, + mac_main VARCHAR(40) NULL, + cpu_summary VARCHAR(255) NULL, + ram_mb INT NULL, + disk_total_mb BIGINT NULL, + disk_free_mb BIGINT NULL, + max_disk_used_pct TINYINT UNSIGNED NULL, + agent_version VARCHAR(80) NULL, + tag VARCHAR(120) NULL, + itemtype VARCHAR(60) NULL, + first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Baja logica (conserva historial); un nuevo reporte NO reactiva un equipo dado de baja. + status ENUM('active','retired') NOT NULL DEFAULT 'active', + assigned_user_id INT NULL, + department_id INT NULL, + notes TEXT NULL, + raw_file VARCHAR(255) NULL, + extra_json LONGTEXT NULL, + KEY idx_devices_hostname (hostname), + KEY idx_devices_last_seen (last_seen), + CONSTRAINT fk_devices_user FOREIGN KEY (assigned_user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_devices_department FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS device_cpus ( + id INT AUTO_INCREMENT PRIMARY KEY, + device_id INT NOT NULL, + name VARCHAR(255) NULL, + manufacturer VARCHAR(120) NULL, + cores INT NULL, + threads INT NULL, + speed_mhz INT NULL, + KEY idx_device_cpus_device (device_id), + CONSTRAINT fk_device_cpus_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS device_memories ( + id INT AUTO_INCREMENT PRIMARY KEY, + device_id INT NOT NULL, + slot VARCHAR(80) NULL, + capacity_mb INT NULL, + type VARCHAR(60) NULL, + speed VARCHAR(40) NULL, + manufacturer VARCHAR(120) NULL, + serial VARCHAR(120) NULL, + KEY idx_device_memories_device (device_id), + CONSTRAINT fk_device_memories_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS device_disks ( + id INT AUTO_INCREMENT PRIMARY KEY, + device_id INT NOT NULL, + name VARCHAR(190) NULL, + model VARCHAR(190) NULL, + manufacturer VARCHAR(120) NULL, + type VARCHAR(60) NULL, + interface VARCHAR(60) NULL, + size_mb BIGINT NULL, + serial VARCHAR(160) NULL, + KEY idx_device_disks_device (device_id), + CONSTRAINT fk_device_disks_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS device_volumes ( + id INT AUTO_INCREMENT PRIMARY KEY, + device_id INT NOT NULL, + mount VARCHAR(190) NULL, + label VARCHAR(190) NULL, + filesystem VARCHAR(60) NULL, + type VARCHAR(60) NULL, + total_mb BIGINT NULL, + free_mb BIGINT NULL, + KEY idx_device_volumes_device (device_id), + CONSTRAINT fk_device_volumes_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS device_networks ( + id INT AUTO_INCREMENT PRIMARY KEY, + device_id INT NOT NULL, + description VARCHAR(255) NULL, + mac VARCHAR(40) NULL, + ip VARCHAR(64) NULL, + mask VARCHAR(64) NULL, + gateway VARCHAR(64) NULL, + type VARCHAR(60) NULL, + status VARCHAR(40) NULL, + KEY idx_device_networks_device (device_id), + CONSTRAINT fk_device_networks_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS device_monitors ( + id INT AUTO_INCREMENT PRIMARY KEY, + device_id INT NOT NULL, + manufacturer VARCHAR(120) NULL, + model VARCHAR(190) NULL, + serial VARCHAR(120) NULL, + KEY idx_device_monitors_device (device_id), + CONSTRAINT fk_device_monitors_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS device_software ( + id INT AUTO_INCREMENT PRIMARY KEY, + device_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + version VARCHAR(120) NULL, + publisher VARCHAR(190) NULL, + install_date VARCHAR(40) NULL, + KEY idx_device_software_device (device_id), + KEY idx_device_software_name (name(120)), + CONSTRAINT fk_device_software_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Diff entre reportes consecutivos (RAM que bajo, disco nuevo, software instalado/desinstalado). +CREATE TABLE IF NOT EXISTS device_changes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + device_id INT NOT NULL, + change_type VARCHAR(40) NOT NULL, + field VARCHAR(120) NULL, + old_value TEXT NULL, + new_value TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_device_changes_device (device_id, created_at), + CONSTRAINT fk_device_changes_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/database/migrations/024_device_software_category.sql b/backend/database/migrations/024_device_software_category.sql new file mode 100644 index 0000000..be06efe --- /dev/null +++ b/backend/database/migrations/024_device_software_category.sql @@ -0,0 +1,6 @@ +-- Software de equipos: categoria que reporta el agente (application, store, system_component, update...) +-- y bandera `is_component` (actualizaciones, runtimes, drivers, paquetes del sistema) para ocultarlos +-- por defecto en las vistas de software. Idempotente. +ALTER TABLE device_software + ADD COLUMN IF NOT EXISTS category VARCHAR(40) NULL, + ADD COLUMN IF NOT EXISTS is_component TINYINT(1) NOT NULL DEFAULT 0; diff --git a/backend/package-lock.json b/backend/package-lock.json index 74d2f17..831cd3a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,18 +1,19 @@ { "name": "project-linkedcorp-backend", - "version": "1.0.0", + "version": "2.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-backend", - "version": "1.0.0", + "version": "2.14.0", "dependencies": { "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "express-rate-limit": "^7.4.0", + "fast-xml-parser": "^4.5.7", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "multer": "^2.3.0", @@ -407,6 +408,24 @@ "express": ">= 4.11" } }, + "node_modules/fast-xml-parser": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.7.tgz", + "integrity": "sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -1186,6 +1205,18 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 0f37918..18afab1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.14.0", + "version": "2.15.0", "private": true, "type": "module", "main": "src/server.js", @@ -17,6 +17,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "express-rate-limit": "^7.4.0", + "fast-xml-parser": "^4.5.7", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "multer": "^2.3.0", diff --git a/backend/src/app.js b/backend/src/app.js index 08e9347..a764eee 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -24,6 +24,8 @@ import updaterRoutes from './modules/updater/routes.js'; import errorLogsRoutes from './modules/errorLogs/routes.js'; import notesRoutes from './modules/notes/routes.js'; import vaultRoutes from './modules/vault/routes.js'; +import devicesRoutes from './modules/devices/routes.js'; +import inventoryIngestRoutes from './modules/devices/ingest.js'; export const app = express(); @@ -34,6 +36,8 @@ app.set('trust proxy', 1); app.use(helmet({ contentSecurityPolicy: false })); app.use(cors({ origin: env.corsOrigin, credentials: true })); +// Ingesta del agente GLPI: ANTES del parser JSON (usa cuerpo crudo, sin CORS: la llama el agente). +app.use(inventoryIngestRoutes); app.use(express.json({ limit: '2mb' })); app.get('/health', (req, res) => res.json({ success: true, data: { status: 'ok' }, error: null })); @@ -57,6 +61,7 @@ app.use('/api/updater', updaterRoutes); app.use('/api/error-logs', errorLogsRoutes); app.use('/api/notes', notesRoutes); app.use('/api/vault', vaultRoutes); +app.use('/api/devices', devicesRoutes); // Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas // especifico registrado despues (ver ESTADO.md, deuda tecnica). diff --git a/backend/src/modules/devices/controller.js b/backend/src/modules/devices/controller.js new file mode 100644 index 0000000..eadcd26 --- /dev/null +++ b/backend/src/modules/devices/controller.js @@ -0,0 +1,19 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getDevices = asyncHandler(async (req, res) => ok(res, await service.listDevices(req.query))); +export const getDevice = asyncHandler(async (req, res) => ok(res, await service.getDevice(Number(req.params.id)))); +export const putDevice = asyncHandler(async (req, res) => ok(res, await service.updateDevice(Number(req.params.id), req.body, req.currentUser))); +export const getDashboard = asyncHandler(async (req, res) => ok(res, await service.getDashboard())); +export const getSoftware = asyncHandler(async (req, res) => ok(res, await service.listSoftware(req.query.q, req.query.all))); +export const getSoftwareDevices = asyncHandler(async (req, res) => ok(res, await service.devicesWithSoftware(String(req.query.name || '')))); + +export const getRaw = asyncHandler(async (req, res) => { + const { full, file } = await service.getRaw(Number(req.params.id)); + res.download(full, file); +}); + +export const getTokens = asyncHandler(async (req, res) => ok(res, await service.listTokens())); +export const postToken = asyncHandler(async (req, res) => ok(res, await service.createToken(req.body.label, req.currentUser), 201)); +export const patchToken = asyncHandler(async (req, res) => ok(res, await service.setTokenActive(Number(req.params.id), !!req.body.isActive, req.currentUser))); +export const deleteToken = asyncHandler(async (req, res) => ok(res, await service.removeToken(Number(req.params.id), req.currentUser))); diff --git a/backend/src/modules/devices/ingest.js b/backend/src/modules/devices/ingest.js new file mode 100644 index 0000000..1ea694d --- /dev/null +++ b/backend/src/modules/devices/ingest.js @@ -0,0 +1,124 @@ +import crypto from 'node:crypto'; +import zlib from 'node:zlib'; +import fs from 'node:fs'; +import path from 'node:path'; +import express from 'express'; +import { env } from '../../config/env.js'; +import { HttpError, asyncHandler } from '../../utils/http.js'; +import * as repo from './repository.js'; +import { parseInventoryText, normalizeInventory } from './normalizer.js'; + +// Ingesta del agente GLPI. NO usa JWT de usuario: cada instalacion lleva un token (guardado como +// hash en inventory_tokens) que el agente manda como password Basic, Bearer, X-Ingest-Token o +// ?token=. Va montada ANTES de express.json (cuerpo crudo) en app.js. + +const MAX_BYTES = Number(process.env.INVENTORY_MAX_BYTES || 15 * 1024 * 1024); +const MAX_INFLATED = MAX_BYTES * 4; // tope de inflado (anti zip-bomb) +const REPORT_EVERY_HOURS = 24; + +const sha256 = (v) => crypto.createHash('sha256').update(v).digest('hex'); + +function extractToken(req) { + if (req.headers['x-ingest-token']) return String(req.headers['x-ingest-token']).trim(); + const auth = req.headers.authorization || ''; + if (auth.startsWith('Bearer ')) return auth.slice(7).trim(); + if (auth.startsWith('Basic ')) { + try { + const dec = Buffer.from(auth.slice(6), 'base64').toString('utf8'); + return dec.includes(':') ? dec.slice(dec.indexOf(':') + 1) : dec; + } catch { /* token invalido: cae abajo */ } + } + if (req.query?.token) return String(req.query.token).trim(); + return null; +} + +function inflate(buf) { + const opts = { maxOutputLength: MAX_INFLATED }; + if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) return zlib.gunzipSync(buf, opts); + if (buf.length > 2 && buf[0] === 0x78) return zlib.inflateSync(buf, opts); + return buf; +} + +function detectFormat(text) { + const t = text.trimStart(); + if (t.startsWith('{') || t.startsWith('[')) return 'json'; + if (t.startsWith('<')) return 'xml'; + return null; +} + +function saveRaw(deviceid, text, format) { + const dir = path.join(env.storageRoot, 'inventories'); + fs.mkdirSync(dir, { recursive: true }); + // Un archivo por equipo (se sobrescribe): solo interesa el ultimo reporte crudo para depurar. + const name = `${sha256(deviceid).slice(0, 24)}.${format}`; + fs.writeFileSync(path.join(dir, name), text); + return name; +} + +const ok = (res, format) => (format === 'xml' + ? res.type('application/xml').send('') + : res.json({ status: 'ok' })); + +const receive = asyncHandler(async (req, res) => { + const token = extractToken(req); + const tokenRow = token ? await repo.findActiveTokenByHash(sha256(token)) : null; + // Log de diagnostico de TODA peticion del agente (sin imprimir el token): util para depurar la conexion. + console.log(`[inventario] ${req.method} ${req.originalUrl} desde ${req.ip} type=${req.headers['content-type'] || '-'} enc=${req.headers['content-encoding'] || '-'} bytes=${req.headers['content-length'] || '?'} token=${token ? (tokenRow ? 'valido' : 'INVALIDO') : 'ausente'}`); + if (!tokenRow) { + // El agente (LWP) manda las credenciales solo despues de recibir este desafio Basic. + res.set('WWW-Authenticate', 'Basic realm="Project inventory"'); + throw new HttpError(401, 'No autorizado'); + } + repo.touchToken(tokenRow.id).catch(() => {}); + + const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0); + if (!body.length) throw new HttpError(400, 'Cuerpo vacio'); + + let text; + try { + text = inflate(body).toString('utf8'); + } catch { + throw new HttpError(400, 'Cuerpo comprimido invalido o demasiado grande'); + } + const format = detectFormat(text); + if (!format) throw new HttpError(400, 'Formato no reconocido'); + + let parsed; + try { + parsed = parseInventoryText(text, format); + } catch { + throw new HttpError(400, 'No se pudo interpretar el inventario'); + } + + // Handshake del agente antes de enviar el inventario: se contesta sin guardar nada. + if (parsed.action === 'contact' || parsed.action === 'prolog') { + if (format === 'xml') { + return res.type('application/xml').send( + `SEND${REPORT_EVERY_HOURS}` + ); + } + return res.json({ status: 'ok', expiration: String(REPORT_EVERY_HOURS) }); + } + if (parsed.action && parsed.action !== 'inventory') return ok(res, format); + + if (!parsed.deviceid) throw new HttpError(400, 'Inventario sin deviceid'); + const normalized = normalizeInventory(parsed); + const rawFile = saveRaw(parsed.deviceid, text, format); + const result = await repo.upsertInventory(parsed.deviceid, normalized, rawFile); + console.log(`[inventario] ${result.created ? 'nuevo' : 'actualizado'} #${result.id} ${normalized.device.hostname || parsed.deviceid} (${format}, ${result.changeCount} cambios)`); + return ok(res, format); +}); + +const router = express.Router(); +const rawBody = express.raw({ type: () => true, limit: MAX_BYTES }); + +// Ruta propia + alias de la ruta nativa de GLPI (para no parchear el agente). +router.post('/api/inventory', rawBody, receive); +router.post('/front/inventory.php', rawBody, receive); +// Cualquier otro metodo/ruta que el agente pruebe bajo estas rutas queda registrado. +router.all(['/api/inventory', '/front/inventory.php'], (req, res) => { + console.log(`[inventario] ${req.method} ${req.originalUrl} desde ${req.ip} (metodo no soportado)`); + res.status(405).json({ success: false, data: null, error: 'Usa POST' }); +}); + +export default router; diff --git a/backend/src/modules/devices/normalizer.js b/backend/src/modules/devices/normalizer.js new file mode 100644 index 0000000..daeb0fc --- /dev/null +++ b/backend/src/modules/devices/normalizer.js @@ -0,0 +1,194 @@ +import { XMLParser } from 'fast-xml-parser'; + +// Convierte el inventario del agente GLPI (JSON o XML) a un objeto normalizado para el modelo +// `devices*`. El JSON nativo y el XML comparten nombres de campo (el XML en MAYUSCULAS), asi que +// el XML se pasa a minusculas y ambos formatos usan el mismo mapeo. Todo es tolerante: un campo +// ausente es null, nunca una excepcion (el formato varia por version del agente y por SO). + +const XML_ARRAY_SECTIONS = new Set([ + 'cpus', 'memories', 'storages', 'drives', 'networks', 'monitors', 'softwares', 'printers', + 'users', 'videos', 'sounds', 'batteries', 'controllers', 'ports', 'slots', 'antivirus', +]); + +const xmlParser = new XMLParser({ ignoreAttributes: true, parseTagValue: false, trimValues: true }); + +function lowerKeys(value) { + if (Array.isArray(value)) return value.map(lowerKeys); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k.toLowerCase(), lowerKeys(v)])); + } + return value; +} + +const asArray = (v) => (v == null || v === '' ? [] : Array.isArray(v) ? v : [v]); +const str = (v, max = 255) => { + if (v == null) return null; + const s = String(typeof v === 'object' ? '' : v).trim(); + return s ? s.slice(0, max) : null; +}; +const num = (v) => { + if (v == null || v === '') return null; + const n = Number(String(v).replace(/[^\d.-]/g, '')); + return Number.isFinite(n) ? Math.round(n) : null; +}; + +// Devuelve { content, deviceid, itemtype, action, tag } o null si no se reconoce estructura. +export function parseInventoryText(text, format) { + if (format === 'json') { + const obj = JSON.parse(text); + return { + action: obj.action || null, + deviceid: str(obj.deviceid || obj.content?.deviceid), + itemtype: str(obj.itemtype, 60), + tag: str(obj.tag, 120), + content: lowerKeys(obj.content || obj), + }; + } + const parsed = lowerKeys(xmlParser.parse(text)); + const req = parsed.request || parsed; + const content = req.content || {}; + for (const key of XML_ARRAY_SECTIONS) { + if (content[key] !== undefined) content[key] = asArray(content[key]); + } + return { + action: str(req.query) === 'PROLOG' ? 'prolog' : (str(req.query) || '').toLowerCase() || null, + deviceid: str(req.deviceid), + itemtype: null, + tag: str(content.accountinfo?.keyvalue, 120), + content, + }; +} + +// Software que NO es una aplicacion "de usuario": actualizaciones, runtimes, drivers y paquetes del +// sistema. Se decide por la categoria que reporta el agente (system_category) y, para lo que el +// agente marca como "application"/"store" pero es ruido, por el nombre. +const COMPONENT_CATEGORIES = new Set(['system', 'system_component', 'update', 'security_update', 'hotfix']); +const COMPONENT_NAME = new RegExp([ + '^KB\\d{6,8}$', '\\(KB\\d{6,8}\\)', 'redistributable', 'runtime package', '\\.net native', '\\.net (host|runtime|sdk|targeting|apphost|toolset)', + 'windowsappruntime', 'ui xaml', 'advertising sdk', 'vclibs', 'driver', 'controlador', 'firmware', 'chipset', + 'management engine', 'rapid storage', 'serial io', 'hardware accelerated', 'compatibility fix', 'subsystem for linux', + 'webview2', 'update health tools', 'pc health', 'comprobaci[oó]n de estado', 'desinstalar impresora', 'printer uninstall', + '^microsoftwindows\\.', '^[0-9a-f]{8}-[0-9a-f]{4}-', '^windows sdk', 'software development kit.*(windows|arm|x64|x86)', +].join('|'), 'i'); +function isComponent(name, category) { + return COMPONENT_CATEGORIES.has((category || '').toLowerCase()) || COMPONENT_NAME.test(name || ''); +} + +const LAPTOP_CHASSIS = /laptop|notebook|portable|hand held|sub notebook|convertible|tablet|detachable|netbook/i; +const SERVER_CHASSIS = /server|rack|blade|main server|multi-system/i; + +function detectKind(parsed, hw, os) { + const type = (parsed.itemtype || '').toLowerCase(); + if (type === 'printer') return 'printer'; + if (type === 'networkequipment') return 'network'; + const chassis = `${hw.chassis_type || ''}`; + if (hw.vmsystem && !/physical/i.test(hw.vmsystem)) return 'virtual'; + if (LAPTOP_CHASSIS.test(chassis)) return 'laptop'; + if (SERVER_CHASSIS.test(chassis) || /server/i.test(`${os.name || ''} ${os.full_name || ''} ${hw.osname || ''}`)) return 'server'; + if (chassis) return 'desktop'; + return 'other'; +} + +export function normalizeInventory(parsed) { + const c = parsed.content || {}; + const hw = c.hardware || {}; + const bios = c.bios || {}; + const os = c.operatingsystem || {}; + const isAgentless = !!(c.network_device || c.printers?.length) && !c.hardware?.name; + + // Equipos descubiertos por SNMP (netinventory): vienen como network_device o printers. + const nd = c.network_device || {}; + const printer = asArray(c.printers)[0] || {}; + + const cpus = asArray(c.cpus).map((x) => ({ + name: str(x.name), manufacturer: str(x.manufacturer, 120), + cores: num(x.core ?? x.cores), threads: num(x.thread ?? x.threads), speed_mhz: num(x.speed), + })); + + const memories = asArray(c.memories) + .map((x) => ({ + slot: str(x.numslots ?? x.caption ?? x.description, 80), capacity_mb: num(x.capacity), + type: str(x.type, 60), speed: str(x.speed, 40), manufacturer: str(x.manufacturer, 120), serial: str(x.serialnumber ?? x.serial, 120), + })) + .filter((m) => m.capacity_mb); + + const disks = asArray(c.storages).map((x) => ({ + name: str(x.name, 190), model: str(x.model ?? x.description, 190), manufacturer: str(x.manufacturer, 120), + type: str(x.type, 60), interface: str(x.interface, 60), size_mb: num(x.disksize), serial: str(x.serial ?? x.serialnumber, 160), + })); + + const volumes = asArray(c.drives).map((x) => ({ + mount: str(x.letter ?? x.volumn, 190), label: str(x.label, 190), filesystem: str(x.filesystem, 60), + type: str(x.type, 60), total_mb: num(x.total), free_mb: num(x.free), + })); + + const networks = asArray(c.networks) + .filter((x) => !(String(x.virtualdev) === '1' && !x.ipaddress)) + .map((x) => ({ + description: str(x.description), mac: str(x.macaddr, 40), ip: str(x.ipaddress, 64), mask: str(x.ipmask, 64), + gateway: str(x.ipgateway, 64), type: str(x.type, 60), status: str(x.status, 40), + })); + + const monitors = asArray(c.monitors).map((x) => ({ + manufacturer: str(x.manufacturer, 120), model: str(x.caption ?? x.description, 190), serial: str(x.serial, 120), + })); + + const software = asArray(c.softwares) + .map((x) => { + const name = str(x.name); + const category = str(x.system_category, 40); + return { + name, version: str(x.version, 120), publisher: str(x.publisher, 190), install_date: str(x.installdate ?? x.install_date, 40), + category, is_component: isComponent(name, category) ? 1 : 0, + }; + }) + .filter((s) => s.name); + + // Resumenes para listado/KPIs. + const hostname = str(hw.name || nd.name || printer.name || os.fqdn, 190); + const upIfaces = networks.filter((n) => n.ip && !/^(127\.|169\.254\.|::1|fe80)/i.test(n.ip) && !n.ip.includes(':')); + const mainNet = upIfaces.find((n) => /up|connected/i.test(n.status || '')) || upIfaces[0] || networks[0] || {}; + const ramMb = num(hw.memory) || memories.reduce((a, m) => a + (m.capacity_mb || 0), 0) || null; + const realVolumes = volumes.filter((v) => v.total_mb > 0 && !/cd|dvd|removable|network|remote/i.test(v.type || '')); + const diskTotal = realVolumes.reduce((a, v) => a + v.total_mb, 0) || null; + const diskFree = diskTotal ? realVolumes.reduce((a, v) => a + (v.free_mb || 0), 0) : null; + const maxUsed = realVolumes.length + ? Math.max(...realVolumes.map((v) => Math.round(100 - ((v.free_mb || 0) * 100) / v.total_mb))) + : null; + + const kind = isAgentless && printer.name ? 'printer' : detectKind(parsed, hw, os); + const lastUser = str(hw.lastloggeduser ?? asArray(c.users)[0]?.login, 190); + + const device = { + deviceid: parsed.deviceid, + hostname, + kind, + manufacturer: str(bios.smanufacturer ?? nd.manufacturer ?? printer.manufacturer, 160), + model: str(bios.smodel ?? nd.model ?? printer.model ?? printer.name, 190), + serial: str(bios.ssn ?? nd.serial ?? printer.serialnumber ?? printer.serial, 160), + os_name: str(os.full_name ?? os.name ?? hw.osname, 190), + os_version: str(os.version ?? hw.osversion, 120), + os_arch: str(os.arch, 40), + domain: str(os.domain ?? os.fqdn ?? hw.workgroup, 190), + last_user: lastUser, + ip_main: mainNet.ip || str(nd.ips?.[0] ?? nd.ip, 64) || null, + mac_main: mainNet.mac || str(nd.mac, 40) || null, + cpu_summary: cpus.length ? `${cpus[0].name || ''}${cpus.length > 1 ? ` x${cpus.length}` : ''}`.trim().slice(0, 255) || null : null, + ram_mb: ramMb, + disk_total_mb: diskTotal, + disk_free_mb: diskFree, + max_disk_used_pct: maxUsed, + agent_version: str(c.versionclient, 80), + tag: parsed.tag, + itemtype: parsed.itemtype, + }; + + // Lo que no tiene columna propia (impresoras/red SNMP, antivirus, video, bateria) se conserva + // como JSON para mostrarlo en el detalle sin tocar el esquema por cada tipo de equipo. + const extra = {}; + for (const key of ['printers', 'network_device', 'antivirus', 'videos', 'batteries', 'sounds']) { + if (c[key] && (!Array.isArray(c[key]) || c[key].length)) extra[key] = c[key]; + } + + return { device, cpus, memories, disks, volumes, networks, monitors, software, extra }; +} diff --git a/backend/src/modules/devices/repository.js b/backend/src/modules/devices/repository.js new file mode 100644 index 0000000..bda0ca7 --- /dev/null +++ b/backend/src/modules/devices/repository.js @@ -0,0 +1,254 @@ +import { pool } from '../../config/db.js'; + +const DEVICE_COLS = [ + 'hostname', 'kind', 'manufacturer', 'model', 'serial', 'os_name', 'os_version', 'os_arch', 'domain', 'last_user', + 'ip_main', 'mac_main', 'cpu_summary', 'ram_mb', 'disk_total_mb', 'disk_free_mb', 'max_disk_used_pct', + 'agent_version', 'tag', 'itemtype', +]; + +const CHILD_TABLES = { + cpus: { table: 'device_cpus', cols: ['name', 'manufacturer', 'cores', 'threads', 'speed_mhz'] }, + memories: { table: 'device_memories', cols: ['slot', 'capacity_mb', 'type', 'speed', 'manufacturer', 'serial'] }, + disks: { table: 'device_disks', cols: ['name', 'model', 'manufacturer', 'type', 'interface', 'size_mb', 'serial'] }, + volumes: { table: 'device_volumes', cols: ['mount', 'label', 'filesystem', 'type', 'total_mb', 'free_mb'] }, + networks: { table: 'device_networks', cols: ['description', 'mac', 'ip', 'mask', 'gateway', 'type', 'status'] }, + monitors: { table: 'device_monitors', cols: ['manufacturer', 'model', 'serial'] }, + software: { table: 'device_software', cols: ['name', 'version', 'publisher', 'install_date', 'category', 'is_component'] }, +}; + +// ---------- Tokens de ingesta ---------- + +export async function findActiveTokenByHash(hash) { + const [rows] = await pool.query('SELECT id FROM inventory_tokens WHERE token_hash = ? AND is_active = 1', [hash]); + return rows[0] || null; +} + +export const touchToken = (id) => pool.query('UPDATE inventory_tokens SET last_used_at = NOW() WHERE id = ?', [id]); + +export async function listTokens() { + const [rows] = await pool.query( + `SELECT t.id, t.label, t.is_active, t.last_used_at, t.created_at, u.name AS created_by_name + FROM inventory_tokens t LEFT JOIN users u ON u.id = t.created_by ORDER BY t.id DESC` + ); + return rows; +} + +export async function createToken(label, hash, userId) { + const [r] = await pool.query('INSERT INTO inventory_tokens (label, token_hash, created_by) VALUES (?, ?, ?)', [label, hash, userId]); + return r.insertId; +} + +export const setTokenActive = (id, active) => pool.query('UPDATE inventory_tokens SET is_active = ? WHERE id = ?', [active ? 1 : 0, id]); +export const deleteToken = (id) => pool.query('DELETE FROM inventory_tokens WHERE id = ?', [id]); + +// ---------- Ingesta ---------- + +// Foto de lo que hay hoy en BD (para calcular el diff contra el reporte nuevo). +async function snapshotOf(conn, deviceId) { + const [[dev]] = await conn.query('SELECT hostname, os_name, os_version, ram_mb FROM devices WHERE id = ?', [deviceId]); + const [disks] = await conn.query('SELECT model, serial, size_mb FROM device_disks WHERE device_id = ?', [deviceId]); + const [software] = await conn.query('SELECT name, version FROM device_software WHERE device_id = ?', [deviceId]); + return { dev, disks, software }; +} + +function diffSnapshots(before, after) { + const changes = []; + for (const f of ['hostname', 'os_name', 'os_version', 'ram_mb']) { + if ((before.dev[f] ?? null) !== (after.dev[f] ?? null)) { + changes.push({ change_type: 'field', field: f, old_value: before.dev[f], new_value: after.dev[f] }); + } + } + const diskKey = (d) => `${d.model || ''}|${d.serial || ''}|${d.size_mb || ''}`; + const swKey = (s) => `${s.name}|${s.version || ''}`; + const compare = (a, b, keyFn, labelFn, addType, removeType) => { + const ka = new Set(a.map(keyFn)); + const kb = new Set(b.map(keyFn)); + for (const x of b) if (!ka.has(keyFn(x))) changes.push({ change_type: addType, field: null, old_value: null, new_value: labelFn(x) }); + for (const x of a) if (!kb.has(keyFn(x))) changes.push({ change_type: removeType, field: null, old_value: labelFn(x), new_value: null }); + }; + compare(before.disks, after.disks, diskKey, (d) => `${d.model || 'Disco'} ${d.size_mb ? Math.round(d.size_mb / 1024) + ' GB' : ''}`.trim(), 'disk_added', 'disk_removed'); + compare(before.software, after.software, swKey, (s) => `${s.name}${s.version ? ` ${s.version}` : ''}`, 'software_installed', 'software_removed'); + return changes; +} + +// Upsert por deviceid. Reemplaza todos los componentes dentro de una transaccion y registra el diff. +// Un equipo dado de baja (status=retired) sigue actualizando datos pero NO se reactiva solo. +export async function upsertInventory(deviceid, n, rawFile) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [[existing]] = await conn.query('SELECT id FROM devices WHERE deviceid = ? FOR UPDATE', [deviceid]); + const d = n.device; + const values = DEVICE_COLS.map((c) => d[c] ?? null); + const extraJson = Object.keys(n.extra).length ? JSON.stringify(n.extra) : null; + + let deviceId; + let before = null; + if (existing) { + deviceId = existing.id; + before = await snapshotOf(conn, deviceId); + await conn.query( + `UPDATE devices SET ${DEVICE_COLS.map((c) => `${c} = ?`).join(', ')}, last_seen = NOW(), raw_file = ?, extra_json = ? WHERE id = ?`, + [...values, rawFile, extraJson, deviceId] + ); + for (const { table } of Object.values(CHILD_TABLES)) await conn.query(`DELETE FROM ${table} WHERE device_id = ?`, [deviceId]); + } else { + const [r] = await conn.query( + `INSERT INTO devices (deviceid, ${DEVICE_COLS.join(', ')}, raw_file, extra_json) VALUES (?, ${DEVICE_COLS.map(() => '?').join(', ')}, ?, ?)`, + [deviceid, ...values, rawFile, extraJson] + ); + deviceId = r.insertId; + } + + for (const [key, { table, cols }] of Object.entries(CHILD_TABLES)) { + const rows = n[key]; + if (!rows.length) continue; + await conn.query( + `INSERT INTO ${table} (device_id, ${cols.join(', ')}) VALUES ?`, + [rows.map((row) => [deviceId, ...cols.map((c) => row[c] ?? null)])] + ); + } + + let changeCount = 0; + if (before) { + const after = { + dev: d, + disks: n.disks, + software: n.software, + }; + const changes = diffSnapshots(before, after); + changeCount = changes.length; + if (changes.length) { + await conn.query( + 'INSERT INTO device_changes (device_id, change_type, field, old_value, new_value) VALUES ?', + [changes.map((c) => [deviceId, c.change_type, c.field, c.old_value == null ? null : String(c.old_value), c.new_value == null ? null : String(c.new_value)])] + ); + } + } + await conn.commit(); + return { id: deviceId, created: !existing, changeCount }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } +} + +// ---------- Consultas de la UI ---------- + +export async function findAll({ search, kind, status, staleDays, diskFull }) { + const where = []; + const params = []; + if (status) { where.push('d.status = ?'); params.push(status); } + if (kind) { where.push('d.kind = ?'); params.push(kind); } + if (staleDays) { where.push('d.last_seen < NOW() - INTERVAL ? DAY'); params.push(staleDays); } + if (diskFull) where.push('d.max_disk_used_pct >= 90'); + if (search) { + where.push(`(d.hostname LIKE ? OR d.serial LIKE ? OR d.model LIKE ? OR d.ip_main LIKE ? OR d.mac_main LIKE ? OR d.last_user LIKE ? OR u.name LIKE ?)`); + params.push(...Array(7).fill(`%${search}%`)); + } + const [rows] = await pool.query( + `SELECT d.id, d.hostname, d.kind, d.manufacturer, d.model, d.serial, d.os_name, d.os_version, d.ip_main, d.mac_main, + d.last_user, d.ram_mb, d.disk_total_mb, d.max_disk_used_pct, d.first_seen, d.last_seen, d.status, + d.assigned_user_id, u.name AS assigned_user_name, d.department_id, dep.name AS department_name + FROM devices d + LEFT JOIN users u ON u.id = d.assigned_user_id + LEFT JOIN departments dep ON dep.id = d.department_id + ${where.length ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY d.hostname`, + params + ); + return rows; +} + +export async function findById(id) { + const [[device]] = await pool.query( + `SELECT d.*, u.name AS assigned_user_name, dep.name AS department_name + FROM devices d + LEFT JOIN users u ON u.id = d.assigned_user_id + LEFT JOIN departments dep ON dep.id = d.department_id + WHERE d.id = ?`, + [id] + ); + if (!device) return null; + const detail = { ...device, extra: device.extra_json ? JSON.parse(device.extra_json) : null }; + delete detail.extra_json; + for (const [key, { table }] of Object.entries(CHILD_TABLES)) { + const order = key === 'software' ? 'name' : 'id'; + const [rows] = await pool.query(`SELECT * FROM ${table} WHERE device_id = ? ORDER BY ${order}`, [id]); + detail[key] = rows; + } + const [changes] = await pool.query( + 'SELECT id, change_type, field, old_value, new_value, created_at FROM device_changes WHERE device_id = ? ORDER BY created_at DESC, id DESC LIMIT 200', + [id] + ); + detail.changes = changes; + return detail; +} + +export async function updateManual(id, { status, assignedUserId, departmentId, notes }) { + await pool.query( + 'UPDATE devices SET status = ?, assigned_user_id = ?, department_id = ?, notes = ? WHERE id = ?', + [status, assignedUserId, departmentId, notes, id] + ); +} + +export async function findRawFile(id) { + const [[row]] = await pool.query('SELECT raw_file FROM devices WHERE id = ?', [id]); + return row?.raw_file || null; +} + +// Por defecto solo aplicaciones; `all` incluye actualizaciones/runtimes/drivers (is_component). +export async function listSoftware({ search, all }) { + const params = []; + const clauses = []; + if (!all) clauses.push('s.is_component = 0'); + if (search) { clauses.push('(s.name LIKE ? OR s.publisher LIKE ?)'); params.push(`%${search}%`, `%${search}%`); } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const [rows] = await pool.query( + `SELECT s.name, MAX(s.publisher) AS publisher, COUNT(DISTINCT s.device_id) AS device_count, + COUNT(DISTINCT s.version) AS version_count + FROM device_software s JOIN devices d ON d.id = s.device_id AND d.status = 'active' + ${where} GROUP BY s.name ORDER BY device_count DESC, s.name LIMIT 1000`, + params + ); + return rows.map((r) => ({ ...r, device_count: Number(r.device_count), version_count: Number(r.version_count) })); +} + +export async function listDevicesWithSoftware(name) { + const [rows] = await pool.query( + `SELECT d.id, d.hostname, d.kind, d.last_user, s.version + FROM device_software s JOIN devices d ON d.id = s.device_id + WHERE s.name = ? AND d.status = 'active' ORDER BY d.hostname`, + [name] + ); + return rows; +} + +export async function dashboard(staleDays) { + const one = async (sql, params = []) => (await pool.query(sql, params))[0]; + const [[totals]] = await pool.query( + `SELECT COUNT(*) AS total, + SUM(last_seen < NOW() - INTERVAL ? DAY) AS stale, + SUM(max_disk_used_pct >= 90) AS disk_full, + SUM(ram_mb IS NOT NULL AND ram_mb < 4096) AS low_ram + FROM devices WHERE status = 'active'`, + [staleDays] + ); + const byKind = await one(`SELECT kind AS label, COUNT(*) AS value FROM devices WHERE status = 'active' GROUP BY kind ORDER BY value DESC`); + const byOs = await one(`SELECT COALESCE(os_name, '—') AS label, COUNT(*) AS value FROM devices WHERE status = 'active' GROUP BY os_name ORDER BY value DESC LIMIT 8`); + const topSoftware = await one( + `SELECT s.name AS label, COUNT(DISTINCT s.device_id) AS value + FROM device_software s JOIN devices d ON d.id = s.device_id AND d.status = 'active' + WHERE s.is_component = 0 + GROUP BY s.name ORDER BY value DESC LIMIT 8` + ); + const n = (v) => Number(v || 0); + return { + total: n(totals.total), stale: n(totals.stale), diskFull: n(totals.disk_full), lowRam: n(totals.low_ram), + byKind: byKind.map((r) => ({ ...r, value: n(r.value) })), + byOs: byOs.map((r) => ({ ...r, value: n(r.value) })), + topSoftware: topSoftware.map((r) => ({ ...r, value: n(r.value) })), + }; +} diff --git a/backend/src/modules/devices/routes.js b/backend/src/modules/devices/routes.js new file mode 100644 index 0000000..f9bf792 --- /dev/null +++ b/backend/src/modules/devices/routes.js @@ -0,0 +1,27 @@ +import { Router } from 'express'; +import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js'; +import { + getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw, + getTokens, postToken, patchToken, deleteToken, +} from './controller.js'; + +const router = Router(); + +// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente, solo admin/superadmin. +const CAN_VIEW = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER]; +const CAN_MANAGE_TOKENS = [ROLES.SUPERADMIN, ROLES.ADMIN]; + +router.use(requireAuth, requireRole(...CAN_VIEW)); +router.get('/dashboard', getDashboard); +router.get('/software', getSoftware); +router.get('/software/devices', getSoftwareDevices); +router.get('/tokens', requireRole(...CAN_MANAGE_TOKENS), getTokens); +router.post('/tokens', requireRole(...CAN_MANAGE_TOKENS), postToken); +router.patch('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), patchToken); +router.delete('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), deleteToken); +router.get('/', getDevices); +router.get('/:id', getDevice); +router.get('/:id/raw', getRaw); +router.put('/:id', putDevice); + +export default router; diff --git a/backend/src/modules/devices/service.js b/backend/src/modules/devices/service.js new file mode 100644 index 0000000..d8f0cfb --- /dev/null +++ b/backend/src/modules/devices/service.js @@ -0,0 +1,75 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { env } from '../../config/env.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import * as repo from './repository.js'; + +const STATUSES = ['active', 'retired']; +const STALE_DAYS = 7; + +export const listDevices = (filters) => repo.findAll({ + search: filters.q?.trim() || null, + kind: filters.kind || null, + status: filters.status || null, + staleDays: filters.stale ? STALE_DAYS : null, + diskFull: !!filters.diskFull, +}); + +export async function getDevice(id) { + const device = await repo.findById(id); + if (!device) throw new HttpError(404, 'Equipo no encontrado'); + return device; +} + +export async function updateDevice(id, data, actor) { + const current = await getDevice(id); + const status = data.status ?? current.status; + if (!STATUSES.includes(status)) throw new HttpError(400, 'Estado invalido'); + const next = { + status, + assignedUserId: data.assignedUserId === undefined ? current.assigned_user_id : data.assignedUserId || null, + departmentId: data.departmentId === undefined ? current.department_id : data.departmentId || null, + notes: data.notes === undefined ? current.notes : (String(data.notes).trim() || null), + }; + await repo.updateManual(id, next); + await logAudit({ userId: actor.id, action: 'update', entityType: 'device', entityId: id, detail: { hostname: current.hostname, ...next } }); + return getDevice(id); +} + +export async function getRaw(id) { + const file = await repo.findRawFile(id); + if (!file) throw new HttpError(404, 'Sin inventario crudo para este equipo'); + const full = path.join(env.storageRoot, 'inventories', path.basename(file)); + if (!fs.existsSync(full)) throw new HttpError(404, 'Archivo de inventario no encontrado'); + return { full, file }; +} + +export const listSoftware = (q, all) => repo.listSoftware({ search: q?.trim() || null, all: !!all }); +export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name); +export const getDashboard = () => repo.dashboard(STALE_DAYS); + +export const listTokens = () => repo.listTokens(); + +// El token en claro se devuelve UNA sola vez (aqui); en BD queda solo el hash. +export async function createToken(label, actor) { + const clean = String(label || '').trim(); + if (!clean) throw new HttpError(400, 'La etiqueta es obligatoria'); + const token = crypto.randomBytes(24).toString('hex'); + const id = await repo.createToken(clean.slice(0, 120), crypto.createHash('sha256').update(token).digest('hex'), actor.id); + await logAudit({ userId: actor.id, action: 'create', entityType: 'inventory_token', entityId: id, detail: { label: clean } }); + return { id, label: clean, token }; +} + +export async function setTokenActive(id, active, actor) { + await repo.setTokenActive(id, active); + await logAudit({ userId: actor.id, action: active ? 'enable' : 'disable', entityType: 'inventory_token', entityId: id }); + return repo.listTokens(); +} + +export async function removeToken(id, actor) { + await repo.deleteToken(id); + await logAudit({ userId: actor.id, action: 'delete', entityType: 'inventory_token', entityId: id }); + return repo.listTokens(); +} diff --git a/docs/ARQUITECTURA-INVENTARIO.md b/docs/ARQUITECTURA-INVENTARIO.md new file mode 100644 index 0000000..24d18de --- /dev/null +++ b/docs/ARQUITECTURA-INVENTARIO.md @@ -0,0 +1,71 @@ +# Arquitectura de inventario — menú "Equipos" + +Diseño del módulo Equipos de Project. Se trajo de `linkedtrack.linkedcorp.net` (que sigue como +producto aparte, sin cambios) y se reescribió siguiendo las reglas de Project: mismo login, roles, +auditoría, UI Fluent y una sola base `project_linkedcorp`. + +## Decisión de fondo + +| Opción | Veredicto | +|---|---| +| Servidor GLPI completo | Descartado: demasiada administración para solo inventariar. | +| Agente 100% propio | Descartado: reescribir el sondeo de hardware (WMI, EDID, SNMP) es frágil y caro. | +| **Agente GLPI oficial + colector propio dentro de Project** | **Elegido.** El agente recolecta; Project recibe, guarda y muestra. | + +## Flujo + +``` +GLPI Agent (cada PC/servidor) --POST inventario--> https://project.linkedcorp.net/api/inventory + (ingest.js: token -> inflar -> parsear JSON/XML + -> normalizer.js -> upsert por deviceid + -> diff en device_changes) +Navegador (superadmin/admin/it_manager) --JWT--> /api/devices -> menú "Equipos" +``` + +- **Handshake:** el agente primero manda un `contact` (JSON) o `PROLOG` (XML). Se responde sin + guardar nada (`{"status":"ok","expiration":"24"}` o `SEND…`). +- **Ingesta autenticada por token**, no por JWT: tabla `inventory_tokens` (solo hash sha256). El + agente lo manda como contraseña Basic (`user`/`password` del `agent.cfg`), `Bearer`, + `X-Ingest-Token` o `?token=`. Sin token válido → `401`. +- **Rutas:** `POST /api/inventory` (propia; pasa por el Custom Location `/api` de NPM sin cambios) + y alias `POST /front/inventory.php` (ruta nativa de GLPI). Se montan ANTES de `express.json` + porque usan cuerpo crudo. +- **Límites:** cuerpo máx. 15 MB (`INVENTORY_MAX_BYTES`), inflado máx. 4× (anti zip-bomb). +- **Upsert por `deviceid`** (estable), nunca por hostname. Componentes se reemplazan completos en + una transacción; el diff (RAM, disco agregado/retirado, software instalado/desinstalado, SO, + hostname) queda en `device_changes`. +- **Baja lógica:** `status = retired`; un reporte nuevo NO reactiva un equipo dado de baja. +- **Crudo para depurar:** último reporte por equipo en `storage/inventories/.json|xml` + (se sobrescribe; descarga `GET /api/devices/:id/raw`). + +## Normalización + +`backend/src/modules/devices/normalizer.js`: el JSON nativo y el XML (en mayúsculas) comparten +nombres de campo, así que el XML se pasa a minúsculas y ambos usan el mismo mapeo. Todo campo +ausente queda `null` (el formato varía por versión del agente y SO). Tipo de equipo +(`desktop/laptop/server/virtual/printer/network/other`) se infiere de `chassis_type`, `vmsystem`, +nombre del SO e `itemtype`. Lo que no tiene columna (impresoras/red SNMP, antivirus, video, +batería) se guarda en `devices.extra_json`. + +## Instalar el agente en un equipo + +1. Superadmin/admin: menú **Equipos → Agentes → Generar token** (se muestra una sola vez). +2. En el equipo, instalar GLPI Agent (MSI en Windows; `.deb`/`.rpm`/tarball en Linux) con: + ``` + server = https://project.linkedcorp.net/api/inventory + user = project + password = + tag = + ``` + Windows (instalador): `SERVER=… USER=project PASSWORD=`. +3. Forzar un envío: `glpi-agent --force` (Linux) / "Forzar inventario" en la página del agente + (`http://localhost:62354`) en Windows. El equipo aparece en **Equipos** al primer POST. + +## Pendientes / por verificar con un agente real + +- Confirmar que el agente acepta la respuesta del handshake tal cual (si no, ajustar `ingest.js`). +- Capturar un JSON real (`glpi-agent --local=/tmp/inv --json`) y contrastar campos con + `normalizer.js` (hoy basado en el formato documentado del agente). +- Impresoras/red por SNMP (`netinventory`): solo se guarda lo básico + `extra_json`; falta una + vista propia cuando haya un caso real. +- Panel de impresoras con contador de páginas/tóner y export CSV: fase 2. diff --git a/frontend/package.json b/frontend/package.json index a39a4e1..2627b10 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.14.0", + "version": "2.15.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 91df35f..4567907 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -21,6 +21,7 @@ import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx'; import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx'; import NotasPage from '../pages/Notas/NotasPage.jsx'; import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx'; +import EquiposPage from '../pages/Equipos/EquiposPage.jsx'; import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx'; export default function App() { @@ -45,6 +46,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/NavigationView.jsx b/frontend/src/components/NavigationView.jsx index 33626e4..4ac42b6 100644 --- a/frontend/src/components/NavigationView.jsx +++ b/frontend/src/components/NavigationView.jsx @@ -1,5 +1,5 @@ import { NavLink } from 'react-router-dom'; -import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound } from 'lucide-react'; +import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor } from 'lucide-react'; import { useUi } from '../context/ui-context.jsx'; import { useAuth } from '../context/auth-context.jsx'; import { useBranding } from '../context/branding-context.jsx'; @@ -12,6 +12,7 @@ const ITEMS = [ { to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' }, { to: '/notas', icon: StickyNote, labelKey: 'nav_notes' }, { to: '/contrasenas', icon: KeyRound, labelKey: 'nav_passwords' }, + { to: '/equipos', icon: Monitor, labelKey: 'nav_devices', roles: ['superadmin', 'admin', 'it_manager'] }, { to: '/organizacion', icon: Building2, labelKey: 'nav_organization' }, { to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true }, ]; @@ -23,7 +24,7 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) { // En celular el drawer siempre se ve completo (con etiquetas), sin importar el modo // compacto/expandido del riel de escritorio -- son dos cosas independientes. const compact = mode === 'compact' && !mobileOpen; - const items = ITEMS.filter((item) => !item.superadminOnly || user?.roleCode === 'superadmin'); + const items = ITEMS.filter((item) => (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.roles || item.roles.includes(user?.roleCode))); const brandName = branding.name || t('appName'); return (