feat: version 2.15.0 -- instalador del agente GLPI, filtro de componentes y token recuperable

Instalador .bat generado en el navegador, casilla para ocultar actualizaciones/drivers/componentes
en Software, buscador por pestaña en el detalle, GUIDs como componentes y tokens de ingesta
cifrados (migraciones 024 y 025).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-20 08:17:53 -05:00
parent e25c2c3b35
commit d5f09dc91b
15 changed files with 151 additions and 12 deletions
+1 -1
View File
@@ -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).
+1
View File
@@ -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`).
+2 -1
View File
@@ -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.
+7 -1
View File
@@ -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`.
@@ -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;
@@ -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)));
+1 -1
View File
@@ -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 || '');
+9 -4
View File
@@ -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]);
+2 -1
View File
@@ -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);
+10 -1
View File
@@ -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 });
+10
View File
@@ -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=<origen>/api/inventory`, `USER=project`, `PASSWORD=<token>` y `TAG` opcional. El .bat se
auto-eleva, descarga `GLPI-Agent-<ver>-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-<ver>-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`).
+16
View File
@@ -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',
+30 -2
View File
@@ -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() {
<div style={{ fontSize: '.75rem', marginTop: 8 }} className="text-muted">{t('eq_agent_setup_auth')}</div>
</div>
<div className="kpi-card" style={{ marginBottom: 16 }}>
<div className="kpi-label" style={{ marginBottom: 6 }}>{t('eq_installer_title')} (v{GLPI_AGENT_VERSION})</div>
<div style={{ fontSize: '.8rem', marginBottom: 8 }}>{t('eq_installer_hint')} {t('eq_installer_row_hint')}</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<input className="input" style={{ width: 320 }} placeholder={t('eq_installer_token')} value={installToken} onChange={(e) => setInstallToken(e.target.value)} />
<input className="input" style={{ width: 180 }} placeholder={t('eq_installer_tag')} value={installTag} onChange={(e) => setInstallTag(e.target.value)} />
<Button variant="accent" icon={Download} onClick={downloadInstaller} disabled={!installToken.trim()}>{t('eq_installer_download')}</Button>
</div>
<div style={{ fontSize: '.75rem', marginTop: 8 }} className="text-muted">{t('eq_installer_note')}</div>
</div>
<div className="toolbar">
<div className="toolbar-left">
<input className="input" style={{ width: 260 }} placeholder={t('eq_token_label')} value={label} onChange={(e) => setLabel(e.target.value)} />
@@ -447,6 +473,8 @@ function AgentsTab() {
key: 'actions', label: '', sortable: false,
render: (r) => (
<span style={{ display: 'flex', gap: 4 }} onClick={(e) => e.stopPropagation()}>
<button className="icon-btn" title={r.has_secret ? t('eq_installer_download') : t('eq_token_no_secret')} disabled={!r.has_secret}
onClick={() => downloadFromToken(r)}><Download size={15} /></button>
<button className="icon-btn" title={r.is_active ? t('eq_token_disable') : t('eq_token_enable')}
onClick={() => runAction(async () => { await setInventoryTokenActive(r.id, !r.is_active); await reload(); })}><Power size={15} /></button>
<button className="icon-btn" title={t('common_delete')} onClick={() => confirmDelete(r)}><Trash2 size={15} /></button>
@@ -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);
}
+1
View File
@@ -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`);