e65111a7f4
Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico, revocacion de JWT por version de sesion (028), control optimista de edicion (029), logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion de proyectos, accesibilidad de dialogos/controles e i18n. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
7.2 KiB
JavaScript
168 lines
7.2 KiB
JavaScript
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('<?xml version="1.0" encoding="UTF-8"?><REPLY></REPLY>')
|
|
: 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(
|
|
`<?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 = 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;
|