feat: version 2.15.0 -- instalador del agente GLPI, filtro de componentes y token recuperable

Instalador .bat generado en el navegador, casilla para ocultar actualizaciones/drivers/componentes
en Software, buscador por pestaña en el detalle, GUIDs como componentes y tokens de ingesta
cifrados (migraciones 024 y 025).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-20 08:17:53 -05:00
parent e25c2c3b35
commit d5f09dc91b
15 changed files with 151 additions and 12 deletions
+1 -1
View File
@@ -127,7 +127,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
- `forgot-password` responde SIEMPRE genérico (exista o no el correo); el envío de correo nunca - `forgot-password` responde SIEMPRE genérico (exista o no el correo); el envío de correo nunca
debe poder tumbar ni cambiar la respuesta de un endpoint (`sendMail` atrapa sus propios errores). debe poder tumbar ni cambiar la respuesta de un endpoint (`sendMail` atrapa sus propios errores).
- Ingesta de Equipos (`POST /api/inventory`): sin JWT, autenticada por token de instalación - Ingesta de Equipos (`POST /api/inventory`): sin JWT, autenticada por token de instalación
(`inventory_tokens`, solo hash); cuerpo crudo con límite de tamaño e inflado. (`inventory_tokens`: hash para validar + copia cifrada AES-256-GCM, revelable solo a admin/superadmin con auditoría); cuerpo crudo con límite de tamaño e inflado.
- Sin SMTP configurado, el sistema degrada a modo dev: los correos de verificación/recuperación se - Sin SMTP configurado, el sistema degrada a modo dev: los correos de verificación/recuperación se
loguean en la consola del backend en vez de enviarse (nunca rompe el flujo). loguean en la consola del backend en vez de enviarse (nunca rompe el flujo).
+1
View File
@@ -125,6 +125,7 @@
- `GET /api/devices/dashboard` — KPIs y top (SO, tipo, software). `GET /api/devices/software` · - `GET /api/devices/dashboard` — KPIs y top (SO, tipo, software). `GET /api/devices/software` ·
`GET /api/devices/software/devices?name=` — software transversal. `GET /api/devices/software/devices?name=` — software transversal.
- `GET /api/devices/:id/raw` — último inventario crudo del agente. - `GET /api/devices/:id/raw` — último inventario crudo del agente.
- `GET /api/devices/tokens/:id/secret` — token en claro (descifrado) para armar el instalador; audita `reveal`.
- `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta - `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta
(el token en claro solo se devuelve en el `POST`). (el token en claro solo se devuelve en el `POST`).
+2 -1
View File
@@ -211,4 +211,5 @@ en producción.
y `department_id` son datos manuales (el agente nunca los toca). Hijos con `ON DELETE CASCADE`. y `department_id` son datos manuales (el agente nunca los toca). Hijos con `ON DELETE CASCADE`.
`device_software.is_component` (migración `024`) marca actualizaciones/runtimes/drivers/paquetes del `device_software.is_component` (migración `024`) marca actualizaciones/runtimes/drivers/paquetes del
sistema (según `system_category` del agente + nombre, ver `normalizer.js#isComponent`); las vistas de sistema (según `system_category` del agente + nombre, ver `normalizer.js#isComponent`); las vistas de
software lo ocultan salvo que se marque la casilla. `inventory_tokens` guarda solo el hash; el token en claro se muestra una vez al crearlo. software lo ocultan salvo que se marque la casilla. `inventory_tokens` guarda el hash (`token_hash`, valida la ingesta) y el token cifrado
(`token_enc`, migración `025`, AES-256-GCM con `ENCRYPTION_KEY`) para regenerar el instalador.
+7 -1
View File
@@ -1,6 +1,6 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [2.15.0] - 2026-10-19 ## [2.15.0] - 2026-10-20
### Added ### Added
- Menú **Equipos**: inventario de TI alimentado por el agente GLPI oficial (sin servidor GLPI). - Menú **Equipos**: inventario de TI alimentado por el agente GLPI oficial (sin servidor GLPI).
Ingesta `POST /api/inventory` (JSON/XML, gzip/zlib) con token de instalación, upsert por Ingesta `POST /api/inventory` (JSON/XML, gzip/zlib) con token de instalación, upsert por
@@ -10,6 +10,12 @@
- Equipos → Software (general y por equipo): casilla "Mostrar actualizaciones, drivers y componentes - Equipos → Software (general y por equipo): casilla "Mostrar actualizaciones, drivers y componentes
del sistema", desmarcada por defecto (solo aplicaciones). Migración `024` agrega `category` e del sistema", desmarcada por defecto (solo aplicaciones). Migración `024` agrega `category` e
`is_component` a `device_software`; se decide por `system_category` del agente y por nombre. `is_component` a `device_software`; se decide por `system_category` del agente y por nombre.
- Equipos → Agentes: "Instalador para Windows": descarga un `.bat` (generado en el navegador, con
servidor y token ya puestos) que baja el MSI oficial del agente GLPI 1.21 y lo instala en silencio
con `SERVER/USER/PASSWORD/TAG`. Si el .msi está junto al .bat, no descarga.
- Tokens de ingesta recuperables: se guardan también cifrados (AES-256-GCM, migración `025`) y cada
token de la lista tiene un botón para descargar el instalador sin volver a pegarlo (queda en
auditoría; los tokens creados antes de esta versión no son recuperables).
- Dependencia backend: `fast-xml-parser`. Diseño traído de linkedtrack (que sigue aparte) en - Dependencia backend: `fast-xml-parser`. Diseño traído de linkedtrack (que sigue aparte) en
`docs/ARQUITECTURA-INVENTARIO.md`. `docs/ARQUITECTURA-INVENTARIO.md`.
@@ -0,0 +1,4 @@
-- Token de ingesta recuperable (cifrado AES-256-GCM con ENCRYPTION_KEY, igual que la clave SMTP)
-- para poder generar el instalador del agente sin volver a pegar el token. token_hash sigue siendo
-- lo que valida la ingesta. Tokens creados antes de esta migracion quedan con token_enc NULL.
ALTER TABLE inventory_tokens ADD COLUMN IF NOT EXISTS token_enc TEXT NULL;
@@ -15,5 +15,6 @@ export const getRaw = asyncHandler(async (req, res) => {
export const getTokens = asyncHandler(async (req, res) => ok(res, await service.listTokens())); export const getTokens = asyncHandler(async (req, res) => ok(res, await service.listTokens()));
export const postToken = asyncHandler(async (req, res) => ok(res, await service.createToken(req.body.label, req.currentUser), 201)); export const postToken = asyncHandler(async (req, res) => ok(res, await service.createToken(req.body.label, req.currentUser), 201));
export const getTokenSecret = asyncHandler(async (req, res) => ok(res, await service.revealToken(Number(req.params.id), req.currentUser)));
export const patchToken = asyncHandler(async (req, res) => ok(res, await service.setTokenActive(Number(req.params.id), !!req.body.isActive, req.currentUser))); export const patchToken = asyncHandler(async (req, res) => ok(res, await service.setTokenActive(Number(req.params.id), !!req.body.isActive, req.currentUser)));
export const deleteToken = asyncHandler(async (req, res) => ok(res, await service.removeToken(Number(req.params.id), req.currentUser))); export const deleteToken = asyncHandler(async (req, res) => ok(res, await service.removeToken(Number(req.params.id), req.currentUser)));
+1 -1
View File
@@ -68,7 +68,7 @@ const COMPONENT_NAME = new RegExp([
'windowsappruntime', 'ui xaml', 'advertising sdk', 'vclibs', 'driver', 'controlador', 'firmware', 'chipset', 'windowsappruntime', 'ui xaml', 'advertising sdk', 'vclibs', 'driver', 'controlador', 'firmware', 'chipset',
'management engine', 'rapid storage', 'serial io', 'hardware accelerated', 'compatibility fix', 'subsystem for linux', 'management engine', 'rapid storage', 'serial io', 'hardware accelerated', 'compatibility fix', 'subsystem for linux',
'webview2', 'update health tools', 'pc health', 'comprobaci[oó]n de estado', 'desinstalar impresora', 'printer uninstall', 'webview2', 'update health tools', 'pc health', 'comprobaci[oó]n de estado', 'desinstalar impresora', 'printer uninstall',
'^microsoftwindows\\.', '^[0-9a-f]{8}-[0-9a-f]{4}-', '^windows sdk', 'software development kit.*(windows|arm|x64|x86)', '^microsoftwindows\\.', '^[{(]?[0-9a-f]{8}-[0-9a-f]{4}-', '^windows sdk', 'software development kit.*(windows|arm|x64|x86)',
].join('|'), 'i'); ].join('|'), 'i');
function isComponent(name, category) { function isComponent(name, category) {
return COMPONENT_CATEGORIES.has((category || '').toLowerCase()) || COMPONENT_NAME.test(name || ''); return COMPONENT_CATEGORIES.has((category || '').toLowerCase()) || COMPONENT_NAME.test(name || '');
+9 -4
View File
@@ -27,17 +27,22 @@ export const touchToken = (id) => pool.query('UPDATE inventory_tokens SET last_u
export async function listTokens() { export async function listTokens() {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT t.id, t.label, t.is_active, t.last_used_at, t.created_at, u.name AS created_by_name `SELECT t.id, t.label, t.is_active, t.last_used_at, t.created_at, u.name AS created_by_name, (t.token_enc IS NOT NULL) AS has_secret
FROM inventory_tokens t LEFT JOIN users u ON u.id = t.created_by ORDER BY t.id DESC` FROM inventory_tokens t LEFT JOIN users u ON u.id = t.created_by ORDER BY t.id DESC`
); );
return rows; return rows.map((r) => ({ ...r, has_secret: !!r.has_secret }));
} }
export async function createToken(label, hash, userId) { export async function createToken(label, hash, enc, userId) {
const [r] = await pool.query('INSERT INTO inventory_tokens (label, token_hash, created_by) VALUES (?, ?, ?)', [label, hash, userId]); const [r] = await pool.query('INSERT INTO inventory_tokens (label, token_hash, token_enc, created_by) VALUES (?, ?, ?, ?)', [label, hash, enc, userId]);
return r.insertId; return r.insertId;
} }
export async function findTokenSecret(id) {
const [[row]] = await pool.query('SELECT token_enc FROM inventory_tokens WHERE id = ?', [id]);
return row?.token_enc || null;
}
export const setTokenActive = (id, active) => pool.query('UPDATE inventory_tokens SET is_active = ? WHERE id = ?', [active ? 1 : 0, id]); export const setTokenActive = (id, active) => pool.query('UPDATE inventory_tokens SET is_active = ? WHERE id = ?', [active ? 1 : 0, id]);
export const deleteToken = (id) => pool.query('DELETE FROM inventory_tokens WHERE id = ?', [id]); export const deleteToken = (id) => pool.query('DELETE FROM inventory_tokens WHERE id = ?', [id]);
+2 -1
View File
@@ -2,7 +2,7 @@ import { Router } from 'express';
import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js'; import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js';
import { import {
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw, getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw,
getTokens, postToken, patchToken, deleteToken, getTokens, postToken, patchToken, deleteToken, getTokenSecret,
} from './controller.js'; } from './controller.js';
const router = Router(); const router = Router();
@@ -17,6 +17,7 @@ router.get('/software', getSoftware);
router.get('/software/devices', getSoftwareDevices); router.get('/software/devices', getSoftwareDevices);
router.get('/tokens', requireRole(...CAN_MANAGE_TOKENS), getTokens); router.get('/tokens', requireRole(...CAN_MANAGE_TOKENS), getTokens);
router.post('/tokens', requireRole(...CAN_MANAGE_TOKENS), postToken); router.post('/tokens', requireRole(...CAN_MANAGE_TOKENS), postToken);
router.get('/tokens/:id/secret', requireRole(...CAN_MANAGE_TOKENS), getTokenSecret);
router.patch('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), patchToken); router.patch('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), patchToken);
router.delete('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), deleteToken); router.delete('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), deleteToken);
router.get('/', getDevices); router.get('/', getDevices);
+10 -1
View File
@@ -4,6 +4,7 @@ import path from 'node:path';
import { env } from '../../config/env.js'; import { env } from '../../config/env.js';
import { HttpError } from '../../utils/http.js'; import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js'; import { logAudit } from '../../utils/audit.js';
import { encrypt, decrypt } from '../../utils/crypto.js';
import * as repo from './repository.js'; import * as repo from './repository.js';
const STATUSES = ['active', 'retired']; const STATUSES = ['active', 'retired'];
@@ -57,11 +58,19 @@ export async function createToken(label, actor) {
const clean = String(label || '').trim(); const clean = String(label || '').trim();
if (!clean) throw new HttpError(400, 'La etiqueta es obligatoria'); if (!clean) throw new HttpError(400, 'La etiqueta es obligatoria');
const token = crypto.randomBytes(24).toString('hex'); const token = crypto.randomBytes(24).toString('hex');
const id = await repo.createToken(clean.slice(0, 120), crypto.createHash('sha256').update(token).digest('hex'), actor.id); const id = await repo.createToken(clean.slice(0, 120), crypto.createHash('sha256').update(token).digest('hex'), encrypt(token), actor.id);
await logAudit({ userId: actor.id, action: 'create', entityType: 'inventory_token', entityId: id, detail: { label: clean } }); await logAudit({ userId: actor.id, action: 'create', entityType: 'inventory_token', entityId: id, detail: { label: clean } });
return { id, label: clean, token }; return { id, label: clean, token };
} }
// Devuelve el token en claro a admin/superadmin para armar el instalador. Queda en auditoria.
export async function revealToken(id, actor) {
const enc = await repo.findTokenSecret(id);
if (!enc) throw new HttpError(404, 'Este token se creo antes de poder recuperarse: genera uno nuevo');
await logAudit({ userId: actor.id, action: 'reveal', entityType: 'inventory_token', entityId: id });
return { token: decrypt(enc) };
}
export async function setTokenActive(id, active, actor) { export async function setTokenActive(id, active, actor) {
await repo.setTokenActive(id, active); await repo.setTokenActive(id, active);
await logAudit({ userId: actor.id, action: active ? 'enable' : 'disable', entityType: 'inventory_token', entityId: id }); await logAudit({ userId: actor.id, action: active ? 'enable' : 'disable', entityType: 'inventory_token', entityId: id });
+10
View File
@@ -61,6 +61,16 @@ batería) se guarda en `devices.extra_json`.
3. Forzar un envío: `glpi-agent --force` (Linux) / "Forzar inventario" en la página del agente 3. Forzar un envío: `glpi-agent --force` (Linux) / "Forzar inventario" en la página del agente
(`http://localhost:62354`) en Windows. El equipo aparece en **Equipos** al primer POST. (`http://localhost:62354`) en Windows. El equipo aparece en **Equipos** al primer POST.
## Instalador con un clic (Windows)
Equipos → Agentes → "Instalador para Windows": genera `instalar-glpi-agent.bat` en el navegador con
`SERVER=<origen>/api/inventory`, `USER=project`, `PASSWORD=<token>` y `TAG` opcional. El .bat se
auto-eleva, descarga `GLPI-Agent-<ver>-x64.msi` de GitHub (versión fijada en
`frontend/src/pages/Equipos/agentInstaller.js#GLPI_AGENT_VERSION`; o usa el .msi que esté junto al
.bat), instala con `msiexec /qn` y `RUNNOW=1`, y verifica que el servicio `glpi-agent` corra. El
token solo se conoce al crearlo, por eso el .bat se genera en la pantalla y no en el servidor.
Linux: usar el instalador oficial `glpi-agent-<ver>-linux-installer.pl --server=… --user=… --password=…`.
## Pendientes / por verificar con un agente real ## Pendientes / por verificar con un agente real
- Confirmar que el agente acepta la respuesta del handshake tal cual (si no, ajustar `ingest.js`). - Confirmar que el agente acepta la respuesta del handshake tal cual (si no, ajustar `ingest.js`).
+16
View File
@@ -191,6 +191,14 @@ export const messages = {
nav_notes: 'Notas', nav_notes: 'Notas',
nav_passwords: 'Contraseñas', nav_passwords: 'Contraseñas',
nav_devices: 'Equipos', nav_devices: 'Equipos',
eq_installer_title: 'Instalador para Windows',
eq_installer_row_hint: 'También puedes descargarlo con el ícono de cada token de la lista.',
eq_token_no_secret: 'Token anterior: no se puede recuperar, genera uno nuevo',
eq_installer_hint: 'Descarga un instalador (.bat) con el servidor y el token ya configurados: se ejecuta con doble clic en el equipo, sin configurar nada más.',
eq_installer_token: 'Token (se llena solo al generar uno)',
eq_installer_tag: 'Etiqueta opcional (ej. Contabilidad)',
eq_installer_download: 'Descargar instalador',
eq_installer_note: 'El .bat contiene el token: compártelo solo con quien instale. Descarga el agente oficial de GitHub; si el equipo no tiene internet, copia el .msi junto al .bat.',
eq_tab_summary: 'Resumen', eq_tab_summary: 'Resumen',
eq_show_components: 'Mostrar actualizaciones, drivers y componentes del sistema', eq_show_components: 'Mostrar actualizaciones, drivers y componentes del sistema',
eq_btn_info: 'Información', eq_btn_info: 'Información',
@@ -647,6 +655,14 @@ export const messages = {
nav_notes: 'Notes', nav_notes: 'Notes',
nav_passwords: 'Passwords', nav_passwords: 'Passwords',
nav_devices: 'Devices', nav_devices: 'Devices',
eq_installer_title: 'Windows installer',
eq_installer_row_hint: 'You can also download it with the icon on each token in the list.',
eq_token_no_secret: 'Older token: cannot be recovered, generate a new one',
eq_installer_hint: 'Download an installer (.bat) with the server and token already set: double-click it on the machine, nothing else to configure.',
eq_installer_token: 'Token (filled in when you generate one)',
eq_installer_tag: 'Optional tag (e.g. Accounting)',
eq_installer_download: 'Download installer',
eq_installer_note: 'The .bat contains the token: share it only with whoever installs. It downloads the official agent from GitHub; if the machine has no internet, put the .msi next to the .bat.',
eq_tab_summary: 'Summary', eq_tab_summary: 'Summary',
eq_show_components: 'Show updates, drivers and system components', eq_show_components: 'Show updates, drivers and system components',
eq_btn_info: 'Information', eq_btn_info: 'Information',
+30 -2
View File
@@ -1,11 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { Pencil, Save, Plus, Trash2, Power, Copy, Info } from 'lucide-react'; import { Pencil, Save, Plus, Trash2, Power, Copy, Info, Download } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx'; import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx'; import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js'; import { useAsync, useAction } from '../../hooks/useAsync.js';
import { import {
listDevices, getDevice, updateDevice, getDevicesDashboard, listSoftware, listSoftwareDevices, listDevices, getDevice, updateDevice, getDevicesDashboard, listSoftware, listSoftwareDevices,
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, getInventoryTokenSecret,
} from '../../services/devices.js'; } from '../../services/devices.js';
import { listUsers } from '../../services/users.js'; import { listUsers } from '../../services/users.js';
import { listDepartments } from '../../services/departments.js'; import { listDepartments } from '../../services/departments.js';
@@ -18,6 +18,7 @@ import { ListView } from '../../components/ListView.jsx';
import { Tabs } from '../../components/Tabs.jsx'; import { Tabs } from '../../components/Tabs.jsx';
import { Badge } from '../../components/Badge.jsx'; import { Badge } from '../../components/Badge.jsx';
import { KpiCard } from '../../components/KpiCard.jsx'; import { KpiCard } from '../../components/KpiCard.jsx';
import { buildInstallerBat, downloadTextFile, GLPI_AGENT_VERSION } from './agentInstaller.js';
const KINDS = ['desktop', 'laptop', 'server', 'virtual', 'printer', 'network', 'other']; const KINDS = ['desktop', 'laptop', 'server', 'virtual', 'printer', 'network', 'other'];
const fmtDate = (v) => (v ? String(v).slice(0, 16).replace('T', ' ') : '—'); const fmtDate = (v) => (v ? String(v).slice(0, 16).replace('T', ' ') : '—');
@@ -381,12 +382,26 @@ function AgentsTab() {
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [created, setCreated] = useState(null); // { label, token } mostrado UNA vez const [created, setCreated] = useState(null); // { label, token } mostrado UNA vez
const serverUrl = `${window.location.origin}/api/inventory`; const serverUrl = `${window.location.origin}/api/inventory`;
const [installToken, setInstallToken] = useState('');
const [installTag, setInstallTag] = useState('');
function downloadFromToken(tk) {
runAction(async () => {
const { token } = await getInventoryTokenSecret(tk.id);
downloadTextFile('instalar-glpi-agent.bat', buildInstallerBat({ serverUrl, token, tag: installTag }));
});
}
function downloadInstaller() {
downloadTextFile('instalar-glpi-agent.bat', buildInstallerBat({ serverUrl, token: installToken.trim(), tag: installTag }));
}
function create() { function create() {
runAction(async () => { runAction(async () => {
const res = await createInventoryToken(label.trim()); const res = await createInventoryToken(label.trim());
setLabel(''); setLabel('');
setCreated(res); setCreated(res);
setInstallToken(res.token);
await reload(); await reload();
}); });
} }
@@ -418,6 +433,17 @@ function AgentsTab() {
<div style={{ fontSize: '.75rem', marginTop: 8 }} className="text-muted">{t('eq_agent_setup_auth')}</div> <div style={{ fontSize: '.75rem', marginTop: 8 }} className="text-muted">{t('eq_agent_setup_auth')}</div>
</div> </div>
<div className="kpi-card" style={{ marginBottom: 16 }}>
<div className="kpi-label" style={{ marginBottom: 6 }}>{t('eq_installer_title')} (v{GLPI_AGENT_VERSION})</div>
<div style={{ fontSize: '.8rem', marginBottom: 8 }}>{t('eq_installer_hint')} {t('eq_installer_row_hint')}</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<input className="input" style={{ width: 320 }} placeholder={t('eq_installer_token')} value={installToken} onChange={(e) => setInstallToken(e.target.value)} />
<input className="input" style={{ width: 180 }} placeholder={t('eq_installer_tag')} value={installTag} onChange={(e) => setInstallTag(e.target.value)} />
<Button variant="accent" icon={Download} onClick={downloadInstaller} disabled={!installToken.trim()}>{t('eq_installer_download')}</Button>
</div>
<div style={{ fontSize: '.75rem', marginTop: 8 }} className="text-muted">{t('eq_installer_note')}</div>
</div>
<div className="toolbar"> <div className="toolbar">
<div className="toolbar-left"> <div className="toolbar-left">
<input className="input" style={{ width: 260 }} placeholder={t('eq_token_label')} value={label} onChange={(e) => setLabel(e.target.value)} /> <input className="input" style={{ width: 260 }} placeholder={t('eq_token_label')} value={label} onChange={(e) => setLabel(e.target.value)} />
@@ -447,6 +473,8 @@ function AgentsTab() {
key: 'actions', label: '', sortable: false, key: 'actions', label: '', sortable: false,
render: (r) => ( render: (r) => (
<span style={{ display: 'flex', gap: 4 }} onClick={(e) => e.stopPropagation()}> <span style={{ display: 'flex', gap: 4 }} onClick={(e) => e.stopPropagation()}>
<button className="icon-btn" title={r.has_secret ? t('eq_installer_download') : t('eq_token_no_secret')} disabled={!r.has_secret}
onClick={() => downloadFromToken(r)}><Download size={15} /></button>
<button className="icon-btn" title={r.is_active ? t('eq_token_disable') : t('eq_token_enable')} <button className="icon-btn" title={r.is_active ? t('eq_token_disable') : t('eq_token_enable')}
onClick={() => runAction(async () => { await setInventoryTokenActive(r.id, !r.is_active); await reload(); })}><Power size={15} /></button> onClick={() => runAction(async () => { await setInventoryTokenActive(r.id, !r.is_active); await reload(); })}><Power size={15} /></button>
<button className="icon-btn" title={t('common_delete')} onClick={() => confirmDelete(r)}><Trash2 size={15} /></button> <button className="icon-btn" title={t('common_delete')} onClick={() => confirmDelete(r)}><Trash2 size={15} /></button>
@@ -0,0 +1,56 @@
// Genera en el navegador el instalador (.bat) del agente GLPI con el servidor y el token ya
// puestos. No pasa por el backend: el token en claro solo existe en la pantalla (en BD queda su hash).
export const GLPI_AGENT_VERSION = '1.21';
export function buildInstallerBat({ serverUrl, token, tag }) {
const ver = GLPI_AGENT_VERSION;
const msiName = `GLPI-Agent-${ver}-x64.msi`;
const safeTag = String(tag || '').replace(/[^\w .-]/g, '').trim();
const lines = [
'@echo off',
'setlocal',
`title Instalador GLPI Agent ${ver} - Project`,
`rem Generado por Project (${serverUrl}). Si el .msi esta junto a este archivo se usa; si no, se descarga.`,
'net session >nul 2>&1',
'if %errorlevel% neq 0 (',
' echo Solicitando permisos de administrador...',
` powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"`,
' exit /b',
')',
`set "MSI=%~dp0${msiName}"`,
'if exist "%MSI%" goto instalar',
`set "MSI=%TEMP%\\${msiName}"`,
'if exist "%MSI%" goto instalar',
`echo Descargando GLPI Agent ${ver}...`,
`powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -UseBasicParsing 'https://github.com/glpi-project/glpi-agent/releases/download/${ver}/${msiName}' -OutFile '%MSI%'"`,
'if errorlevel 1 (',
` echo ERROR: no se pudo descargar. Copie ${msiName} junto a este archivo y ejecute de nuevo.`,
' pause',
' exit /b 1',
')',
':instalar',
'echo Instalando y configurando el agente...',
`start /wait msiexec /i "%MSI%" /qn /norestart SERVER="${serverUrl}" USER="project" PASSWORD="${token}"${safeTag ? ` TAG="${safeTag}"` : ''} RUNNOW=1`,
'set RC=%errorlevel%',
'if %RC% neq 0 if %RC% neq 3010 (',
' echo ERROR: la instalacion fallo ^(codigo %RC%^).',
' pause',
' exit /b %RC%',
')',
'echo.',
'sc query glpi-agent | find "RUNNING" >nul && (echo Listo: el agente esta instalado y en ejecucion. El equipo aparecera en Project en unos minutos.) || (echo Instalado, pero el servicio no esta en ejecucion. Revise services.msc.)',
'pause',
];
return lines.join('\r\n') + '\r\n';
}
export function downloadTextFile(filename, content) {
const url = URL.createObjectURL(new Blob([content], { type: 'application/octet-stream' }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
+1
View File
@@ -14,3 +14,4 @@ export const listInventoryTokens = () => api.get('/devices/tokens');
export const createInventoryToken = (label) => api.post('/devices/tokens', { label }); export const createInventoryToken = (label) => api.post('/devices/tokens', { label });
export const setInventoryTokenActive = (id, isActive) => api.patch(`/devices/tokens/${id}`, { isActive }); export const setInventoryTokenActive = (id, isActive) => api.patch(`/devices/tokens/${id}`, { isActive });
export const deleteInventoryToken = (id) => api.delete(`/devices/tokens/${id}`); export const deleteInventoryToken = (id) => api.delete(`/devices/tokens/${id}`);
export const getInventoryTokenSecret = (id) => api.get(`/devices/tokens/${id}/secret`);