diff --git a/AGENTS.md b/AGENTS.md index f171fbb..aab4058 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,7 +127,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): - `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. + (`inventory_tokens`: hash para validar + copia cifrada AES-256-GCM, revelable solo a admin/superadmin con auditoría); 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). diff --git a/API.md b/API.md index 0b1d4c9..953fc60 100644 --- a/API.md +++ b/API.md @@ -125,6 +125,7 @@ - `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 /api/devices/tokens/:id/secret` — token en claro (descifrado) para armar el instalador; audita `reveal`. - `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta (el token en claro solo se devuelve en el `POST`). diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 2b68435..05d789e 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -211,4 +211,5 @@ en producción. 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. + software lo ocultan salvo que se marque la casilla. `inventory_tokens` guarda el hash (`token_hash`, valida la ingesta) y el token cifrado + (`token_enc`, migración `025`, AES-256-GCM con `ENCRYPTION_KEY`) para regenerar el instalador. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e7bf1d..f54cc3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog - project.linkedcorp.net -## [2.15.0] - 2026-10-19 +## [2.15.0] - 2026-10-20 ### 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 @@ -10,6 +10,12 @@ - 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. +- Equipos → Agentes: "Instalador para Windows": descarga un `.bat` (generado en el navegador, con + servidor y token ya puestos) que baja el MSI oficial del agente GLPI 1.21 y lo instala en silencio + con `SERVER/USER/PASSWORD/TAG`. Si el .msi está junto al .bat, no descarga. +- Tokens de ingesta recuperables: se guardan también cifrados (AES-256-GCM, migración `025`) y cada + token de la lista tiene un botón para descargar el instalador sin volver a pegarlo (queda en + auditoría; los tokens creados antes de esta versión no son recuperables). - Dependencia backend: `fast-xml-parser`. Diseño traído de linkedtrack (que sigue aparte) en `docs/ARQUITECTURA-INVENTARIO.md`. diff --git a/backend/database/migrations/025_inventory_token_secret.sql b/backend/database/migrations/025_inventory_token_secret.sql new file mode 100644 index 0000000..33952bc --- /dev/null +++ b/backend/database/migrations/025_inventory_token_secret.sql @@ -0,0 +1,4 @@ +-- Token de ingesta recuperable (cifrado AES-256-GCM con ENCRYPTION_KEY, igual que la clave SMTP) +-- para poder generar el instalador del agente sin volver a pegar el token. token_hash sigue siendo +-- lo que valida la ingesta. Tokens creados antes de esta migracion quedan con token_enc NULL. +ALTER TABLE inventory_tokens ADD COLUMN IF NOT EXISTS token_enc TEXT NULL; diff --git a/backend/src/modules/devices/controller.js b/backend/src/modules/devices/controller.js index eadcd26..24e3705 100644 --- a/backend/src/modules/devices/controller.js +++ b/backend/src/modules/devices/controller.js @@ -15,5 +15,6 @@ export const getRaw = asyncHandler(async (req, res) => { 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 getTokenSecret = asyncHandler(async (req, res) => ok(res, await service.revealToken(Number(req.params.id), req.currentUser))); 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/normalizer.js b/backend/src/modules/devices/normalizer.js index daeb0fc..4c448d9 100644 --- a/backend/src/modules/devices/normalizer.js +++ b/backend/src/modules/devices/normalizer.js @@ -68,7 +68,7 @@ const COMPONENT_NAME = new RegExp([ '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)', + '^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 || ''); diff --git a/backend/src/modules/devices/repository.js b/backend/src/modules/devices/repository.js index bda0ca7..c10df8d 100644 --- a/backend/src/modules/devices/repository.js +++ b/backend/src/modules/devices/repository.js @@ -27,17 +27,22 @@ export const touchToken = (id) => pool.query('UPDATE inventory_tokens SET last_u 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 + `SELECT t.id, t.label, t.is_active, t.last_used_at, t.created_at, u.name AS created_by_name, (t.token_enc IS NOT NULL) AS has_secret FROM inventory_tokens t LEFT JOIN users u ON u.id = t.created_by ORDER BY t.id DESC` ); - return rows; + return rows.map((r) => ({ ...r, has_secret: !!r.has_secret })); } -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]); +export async function createToken(label, hash, enc, userId) { + const [r] = await pool.query('INSERT INTO inventory_tokens (label, token_hash, token_enc, created_by) VALUES (?, ?, ?, ?)', [label, hash, enc, userId]); return r.insertId; } +export async function findTokenSecret(id) { + const [[row]] = await pool.query('SELECT token_enc FROM inventory_tokens WHERE id = ?', [id]); + return row?.token_enc || null; +} + 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]); diff --git a/backend/src/modules/devices/routes.js b/backend/src/modules/devices/routes.js index f9bf792..6d954ff 100644 --- a/backend/src/modules/devices/routes.js +++ b/backend/src/modules/devices/routes.js @@ -2,7 +2,7 @@ import { Router } from 'express'; import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js'; import { getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw, - getTokens, postToken, patchToken, deleteToken, + getTokens, postToken, patchToken, deleteToken, getTokenSecret, } from './controller.js'; const router = Router(); @@ -17,6 +17,7 @@ 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.get('/tokens/:id/secret', requireRole(...CAN_MANAGE_TOKENS), getTokenSecret); router.patch('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), patchToken); router.delete('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), deleteToken); router.get('/', getDevices); diff --git a/backend/src/modules/devices/service.js b/backend/src/modules/devices/service.js index d8f0cfb..329ad70 100644 --- a/backend/src/modules/devices/service.js +++ b/backend/src/modules/devices/service.js @@ -4,6 +4,7 @@ import path from 'node:path'; import { env } from '../../config/env.js'; import { HttpError } from '../../utils/http.js'; import { logAudit } from '../../utils/audit.js'; +import { encrypt, decrypt } from '../../utils/crypto.js'; import * as repo from './repository.js'; const STATUSES = ['active', 'retired']; @@ -57,11 +58,19 @@ 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); + const id = await repo.createToken(clean.slice(0, 120), crypto.createHash('sha256').update(token).digest('hex'), encrypt(token), actor.id); await logAudit({ userId: actor.id, action: 'create', entityType: 'inventory_token', entityId: id, detail: { label: clean } }); return { id, label: clean, token }; } +// Devuelve el token en claro a admin/superadmin para armar el instalador. Queda en auditoria. +export async function revealToken(id, actor) { + const enc = await repo.findTokenSecret(id); + if (!enc) throw new HttpError(404, 'Este token se creo antes de poder recuperarse: genera uno nuevo'); + await logAudit({ userId: actor.id, action: 'reveal', entityType: 'inventory_token', entityId: id }); + return { token: decrypt(enc) }; +} + 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 }); diff --git a/docs/ARQUITECTURA-INVENTARIO.md b/docs/ARQUITECTURA-INVENTARIO.md index 24d18de..5e8d1d5 100644 --- a/docs/ARQUITECTURA-INVENTARIO.md +++ b/docs/ARQUITECTURA-INVENTARIO.md @@ -61,6 +61,16 @@ batería) se guarda en `devices.extra_json`. 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. +## Instalador con un clic (Windows) + +Equipos → Agentes → "Instalador para Windows": genera `instalar-glpi-agent.bat` en el navegador con +`SERVER=/api/inventory`, `USER=project`, `PASSWORD=` y `TAG` opcional. El .bat se +auto-eleva, descarga `GLPI-Agent--x64.msi` de GitHub (versión fijada en +`frontend/src/pages/Equipos/agentInstaller.js#GLPI_AGENT_VERSION`; o usa el .msi que esté junto al +.bat), instala con `msiexec /qn` y `RUNNOW=1`, y verifica que el servicio `glpi-agent` corra. El +token solo se conoce al crearlo, por eso el .bat se genera en la pantalla y no en el servidor. +Linux: usar el instalador oficial `glpi-agent--linux-installer.pl --server=… --user=… --password=…`. + ## Pendientes / por verificar con un agente real - Confirmar que el agente acepta la respuesta del handshake tal cual (si no, ajustar `ingest.js`). diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index e05b1f6..3b7c659 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -191,6 +191,14 @@ export const messages = { nav_notes: 'Notas', nav_passwords: 'Contraseñas', nav_devices: 'Equipos', + eq_installer_title: 'Instalador para Windows', + eq_installer_row_hint: 'También puedes descargarlo con el ícono de cada token de la lista.', + eq_token_no_secret: 'Token anterior: no se puede recuperar, genera uno nuevo', + eq_installer_hint: 'Descarga un instalador (.bat) con el servidor y el token ya configurados: se ejecuta con doble clic en el equipo, sin configurar nada más.', + eq_installer_token: 'Token (se llena solo al generar uno)', + eq_installer_tag: 'Etiqueta opcional (ej. Contabilidad)', + eq_installer_download: 'Descargar instalador', + eq_installer_note: 'El .bat contiene el token: compártelo solo con quien instale. Descarga el agente oficial de GitHub; si el equipo no tiene internet, copia el .msi junto al .bat.', eq_tab_summary: 'Resumen', eq_show_components: 'Mostrar actualizaciones, drivers y componentes del sistema', eq_btn_info: 'Información', @@ -647,6 +655,14 @@ export const messages = { nav_notes: 'Notes', nav_passwords: 'Passwords', nav_devices: 'Devices', + eq_installer_title: 'Windows installer', + eq_installer_row_hint: 'You can also download it with the icon on each token in the list.', + eq_token_no_secret: 'Older token: cannot be recovered, generate a new one', + eq_installer_hint: 'Download an installer (.bat) with the server and token already set: double-click it on the machine, nothing else to configure.', + eq_installer_token: 'Token (filled in when you generate one)', + eq_installer_tag: 'Optional tag (e.g. Accounting)', + eq_installer_download: 'Download installer', + eq_installer_note: 'The .bat contains the token: share it only with whoever installs. It downloads the official agent from GitHub; if the machine has no internet, put the .msi next to the .bat.', eq_tab_summary: 'Summary', eq_show_components: 'Show updates, drivers and system components', eq_btn_info: 'Information', diff --git a/frontend/src/pages/Equipos/EquiposPage.jsx b/frontend/src/pages/Equipos/EquiposPage.jsx index 1b34292..f922fba 100644 --- a/frontend/src/pages/Equipos/EquiposPage.jsx +++ b/frontend/src/pages/Equipos/EquiposPage.jsx @@ -1,11 +1,11 @@ import { useState } from 'react'; -import { Pencil, Save, Plus, Trash2, Power, Copy, Info } from 'lucide-react'; +import { Pencil, Save, Plus, Trash2, Power, Copy, Info, Download } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAuth } from '../../context/auth-context.jsx'; import { useAsync, useAction } from '../../hooks/useAsync.js'; import { listDevices, getDevice, updateDevice, getDevicesDashboard, listSoftware, listSoftwareDevices, - listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, + listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, getInventoryTokenSecret, } from '../../services/devices.js'; import { listUsers } from '../../services/users.js'; import { listDepartments } from '../../services/departments.js'; @@ -18,6 +18,7 @@ import { ListView } from '../../components/ListView.jsx'; import { Tabs } from '../../components/Tabs.jsx'; import { Badge } from '../../components/Badge.jsx'; import { KpiCard } from '../../components/KpiCard.jsx'; +import { buildInstallerBat, downloadTextFile, GLPI_AGENT_VERSION } from './agentInstaller.js'; const KINDS = ['desktop', 'laptop', 'server', 'virtual', 'printer', 'network', 'other']; const fmtDate = (v) => (v ? String(v).slice(0, 16).replace('T', ' ') : '—'); @@ -381,12 +382,26 @@ function AgentsTab() { const [label, setLabel] = useState(''); const [created, setCreated] = useState(null); // { label, token } mostrado UNA vez const serverUrl = `${window.location.origin}/api/inventory`; + const [installToken, setInstallToken] = useState(''); + const [installTag, setInstallTag] = useState(''); + + function downloadFromToken(tk) { + runAction(async () => { + const { token } = await getInventoryTokenSecret(tk.id); + downloadTextFile('instalar-glpi-agent.bat', buildInstallerBat({ serverUrl, token, tag: installTag })); + }); + } + + function downloadInstaller() { + downloadTextFile('instalar-glpi-agent.bat', buildInstallerBat({ serverUrl, token: installToken.trim(), tag: installTag })); + } function create() { runAction(async () => { const res = await createInventoryToken(label.trim()); setLabel(''); setCreated(res); + setInstallToken(res.token); await reload(); }); } @@ -418,6 +433,17 @@ function AgentsTab() {
{t('eq_agent_setup_auth')}
+
+
{t('eq_installer_title')} (v{GLPI_AGENT_VERSION})
+
{t('eq_installer_hint')} {t('eq_installer_row_hint')}
+
+ setInstallToken(e.target.value)} /> + setInstallTag(e.target.value)} /> + +
+
{t('eq_installer_note')}
+
+
setLabel(e.target.value)} /> @@ -447,6 +473,8 @@ function AgentsTab() { key: 'actions', label: '', sortable: false, render: (r) => ( e.stopPropagation()}> + diff --git a/frontend/src/pages/Equipos/agentInstaller.js b/frontend/src/pages/Equipos/agentInstaller.js new file mode 100644 index 0000000..cc8fea2 --- /dev/null +++ b/frontend/src/pages/Equipos/agentInstaller.js @@ -0,0 +1,56 @@ +// Genera en el navegador el instalador (.bat) del agente GLPI con el servidor y el token ya +// puestos. No pasa por el backend: el token en claro solo existe en la pantalla (en BD queda su hash). +export const GLPI_AGENT_VERSION = '1.21'; + +export function buildInstallerBat({ serverUrl, token, tag }) { + const ver = GLPI_AGENT_VERSION; + const msiName = `GLPI-Agent-${ver}-x64.msi`; + const safeTag = String(tag || '').replace(/[^\w .-]/g, '').trim(); + const lines = [ + '@echo off', + 'setlocal', + `title Instalador GLPI Agent ${ver} - Project`, + `rem Generado por Project (${serverUrl}). Si el .msi esta junto a este archivo se usa; si no, se descarga.`, + 'net session >nul 2>&1', + 'if %errorlevel% neq 0 (', + ' echo Solicitando permisos de administrador...', + ` powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"`, + ' exit /b', + ')', + `set "MSI=%~dp0${msiName}"`, + 'if exist "%MSI%" goto instalar', + `set "MSI=%TEMP%\\${msiName}"`, + 'if exist "%MSI%" goto instalar', + `echo Descargando GLPI Agent ${ver}...`, + `powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -UseBasicParsing 'https://github.com/glpi-project/glpi-agent/releases/download/${ver}/${msiName}' -OutFile '%MSI%'"`, + 'if errorlevel 1 (', + ` echo ERROR: no se pudo descargar. Copie ${msiName} junto a este archivo y ejecute de nuevo.`, + ' pause', + ' exit /b 1', + ')', + ':instalar', + 'echo Instalando y configurando el agente...', + `start /wait msiexec /i "%MSI%" /qn /norestart SERVER="${serverUrl}" USER="project" PASSWORD="${token}"${safeTag ? ` TAG="${safeTag}"` : ''} RUNNOW=1`, + 'set RC=%errorlevel%', + 'if %RC% neq 0 if %RC% neq 3010 (', + ' echo ERROR: la instalacion fallo ^(codigo %RC%^).', + ' pause', + ' exit /b %RC%', + ')', + 'echo.', + 'sc query glpi-agent | find "RUNNING" >nul && (echo Listo: el agente esta instalado y en ejecucion. El equipo aparecera en Project en unos minutos.) || (echo Instalado, pero el servicio no esta en ejecucion. Revise services.msc.)', + 'pause', + ]; + return lines.join('\r\n') + '\r\n'; +} + +export function downloadTextFile(filename, content) { + const url = URL.createObjectURL(new Blob([content], { type: 'application/octet-stream' })); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/services/devices.js b/frontend/src/services/devices.js index 0f70b68..a47e00d 100644 --- a/frontend/src/services/devices.js +++ b/frontend/src/services/devices.js @@ -14,3 +14,4 @@ export const listInventoryTokens = () => api.get('/devices/tokens'); export const createInventoryToken = (label) => api.post('/devices/tokens', { label }); export const setInventoryTokenActive = (id, isActive) => api.patch(`/devices/tokens/${id}`, { isActive }); export const deleteInventoryToken = (id) => api.delete(`/devices/tokens/${id}`); +export const getInventoryTokenSecret = (id) => api.get(`/devices/tokens/${id}/secret`);