feat: version 2.15.0 -- menu Equipos (inventario de TI con agente GLPI)

Ingesta POST /api/inventory (JSON/XML, token de instalacion), upsert por deviceid,
historial de cambios, pestañas Resumen/Equipos/Software/Agentes, detalle con buscador
por pestaña y casilla para ocultar actualizaciones/drivers/componentes del software.
Migraciones 023 y 024. Diseño en docs/ARQUITECTURA-INVENTARIO.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-19 19:05:00 -05:00
parent c33306a881
commit e25c2c3b35
27 changed files with 1730 additions and 11 deletions
+124
View File
@@ -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('<?xml version="1.0" encoding="UTF-8"?><REPLY></REPLY>')
: 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(
`<?xml version="1.0" encoding="UTF-8"?><REPLY><RESPONSE>SEND</RESPONSE><PROLOG_FREQ>${REPORT_EVERY_HOURS}</PROLOG_FREQ></REPLY>`
);
}
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;