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;