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 rateLimit from 'express-rate-limit';
import { promisify } from 'node:util';
import { Worker } from 'node:worker_threads';
import { env } from '../../config/env.js';
import { HttpError, asyncHandler } from '../../utils/http.js';
import * as repo from './repository.js';
import { redactUrl } from '../../utils/redactUrl.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');
const gunzip = promisify(zlib.gunzip);
const uncompress = promisify(zlib.inflate);
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;
}
async function inflate(buf) {
const opts = { maxOutputLength: MAX_INFLATED };
if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) return gunzip(buf, opts);
if (buf.length > 2 && buf[0] === 0x78) return uncompress(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;
}
async function saveRaw(deviceid, text, format) {
const dir = path.join(env.storageRoot, 'inventories');
await fs.promises.mkdir(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}`;
await fs.promises.writeFile(path.join(dir, name), text);
return name;
}
function parseInWorker(text, format) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./parseWorker.js', import.meta.url), {
workerData: { text, format }, resourceLimits: { maxOldGenerationSizeMb: 256 },
});
let settled = false;
const timeout = setTimeout(() => worker.terminate(), 15_000);
function finish(error, value) {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (error) reject(error); else resolve(value);
}
worker.once('message', (value) => value.error ? finish(new Error(value.error)) : finish(null, value));
worker.once('error', (error) => finish(error));
worker.once('exit', (code) => { if (!settled) finish(new Error(`Parser interrumpido (${code})`)); });
});
}
const ok = (res, format) => (format === 'xml'
? res.type('application/xml').send('')
: res.json({ status: 'ok' }));
const receive = asyncHandler(async (req, res) => {
const tokenRow = req.inventoryToken;
const token = req.inventoryTokenPresent;
// Log de diagnostico de TODA peticion del agente (sin imprimir el token): util para depurar la conexion.
console.log(`[inventario] ${req.method} ${redactUrl(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 = (await 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 = await parseInWorker(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 = parsed.normalized;
const rawFile = await 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 });
const limiter = rateLimit({ windowMs: 60_000, limit: 120, standardHeaders: 'draft-7', legacyHeaders: false });
let activeRequests = 0;
const preflight = asyncHandler(async (req, res, next) => {
const token = extractToken(req);
const tokenRow = token ? await repo.findActiveTokenByHash(sha256(token)) : null;
req.inventoryTokenPresent = !!token;
req.inventoryToken = tokenRow;
if (!tokenRow) {
res.set('WWW-Authenticate', 'Basic realm="Project inventory"');
throw new HttpError(401, 'No autorizado');
}
if (activeRequests >= 2) throw new HttpError(503, 'Ingesta ocupada; reintenta');
activeRequests++;
let released = false;
function release() { if (!released) { released = true; activeRequests--; } }
res.once('finish', release);
res.once('close', release);
next();
});
// Ruta propia + alias de la ruta nativa de GLPI (para no parchear el agente).
router.post('/api/inventory', limiter, preflight, rawBody, receive);
router.post('/front/inventory.php', limiter, preflight, 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} ${redactUrl(req.originalUrl)} desde ${req.ip} (metodo no soportado)`);
res.status(405).json({ success: false, data: null, error: 'Usa POST' });
});
export default router;