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:
@@ -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;
|
||||
Reference in New Issue
Block a user