1302 lines
42 KiB
Markdown
1302 lines
42 KiB
Markdown
|
|
# 📦 Plugin GLPI 11: Network Config Backup (`netconfig`)
|
||
|
|
|
||
|
|
> **Compatibilità**: GLPI 11.0.6+ | PHP 8.2+ | MySQL/MariaDB
|
||
|
|
> **Autore**: Your Name / Team | **Licenza**: GPL-3.0-or-later
|
||
|
|
> **Ultimo aggiornamento**: 2026
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📋 Indice
|
||
|
|
1. [Architettura del Plugin](#-1-architettura-del-plugin)
|
||
|
|
2. [Struttura File](#-2-struttura-file)
|
||
|
|
3. [Codice Completo dei File](#-3-codice-completo-dei-file)
|
||
|
|
4. [Script Python per GLPI Agent](#-4-script-python-per-glpi-agent)
|
||
|
|
5. [Configurazione Scheduler (Cron/Supervisor/Systemd)](#-5-configurazione-scheduler)
|
||
|
|
6. [Client API REST GLPI 11](#-6-client-api-rest-glpi-11)
|
||
|
|
7. [Installazione Passo-Passo](#-7-installazione-passo-passo)
|
||
|
|
8. [Risoluzione Problemi Composer](#-8-risoluzione-problemi-composer)
|
||
|
|
9. [Sicurezza e Best Practice](#-9-sicurezza-e-best-practice)
|
||
|
|
10. [Checklist Finale di Deploy](#-10-checklist-finale-di-deploy)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🧱 1. Architettura del Plugin
|
||
|
|
|
||
|
|
```
|
||
|
|
plugins/netconfig/
|
||
|
|
├── setup.php # Registrazione plugin, hook, versioning
|
||
|
|
├── hook.php # Hook install/uninstall
|
||
|
|
├── composer.json # Dipendenze (PSR-4, symfony/diff, guzzle)
|
||
|
|
├── install/
|
||
|
|
│ └── mysql/
|
||
|
|
│ └── install.sql # Schema database
|
||
|
|
├── src/
|
||
|
|
│ ├── Config.php # Classe CommonDBTM: CRUD, massive actions, diff
|
||
|
|
│ └── Agent/
|
||
|
|
│ └── Receive.php # Endpoint API per ricezione da agent
|
||
|
|
├── ajax/
|
||
|
|
│ ├── export.php # Download sicuro configurazioni
|
||
|
|
│ └── agent_receive.php # Fallback endpoint agent
|
||
|
|
├── templates/
|
||
|
|
│ └── config_tab.html.twig # Vista tab GLPI con storico e azioni
|
||
|
|
├── locales/
|
||
|
|
│ ├── en_GB.po
|
||
|
|
│ └── it_IT.po
|
||
|
|
└── README.md # Questo file
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📁 2. Struttura File (Riepilogo)
|
||
|
|
|
||
|
|
| File | Scopo | Note |
|
||
|
|
|------|-------|------|
|
||
|
|
| `setup.php` | Inizializzazione plugin, hook, diritti | Conforme GLPI 11 |
|
||
|
|
| `hook.php` | Installazione/rimozione tabelle DB | Usa `DBConnection` |
|
||
|
|
| `composer.json` | Dipendenze e autoloading PSR-4 | Richiede `composer install` |
|
||
|
|
| `install/mysql/install.sql` | Creazione tabella `glpi_plugin_netconfig_configs` | InnoDB, utf8mb4 |
|
||
|
|
| `src/Config.php` | Logica business: salvataggio, versioning, diff, massive actions | Estende `CommonDBTM` |
|
||
|
|
| `src/Agent/Receive.php` | Endpoint API per ricezione config da agent | Validazione token, hash, cifratura |
|
||
|
|
| `ajax/export.php` | Export configurazioni in formato testo | Controllo diritti, redaction password |
|
||
|
|
| `ajax/agent_receive.php` | Endpoint fallback per agent (se API REST non attiva) | Session-based auth |
|
||
|
|
| `templates/config_tab.html.twig` | Template Twig per visualizzazione storico | Integrazione nativa GLPI |
|
||
|
|
| `netconfig_backup.py` | Script Python per GLPI Agent (esterno al plugin) | Netmiko, YAML config, retry logic |
|
||
|
|
| `glpi-netconfig.service/timer` | Unit systemd per esecuzione schedulata | Alternativa a cron/supervisor |
|
||
|
|
| `GlpiApiClient.php` | Client PHP per API REST GLPI 11 | App-Token + Session-Token, retry |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 💻 3. Codice Completo dei File
|
||
|
|
|
||
|
|
### 🔹 `setup.php`
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
define('PLUGIN_NETCONFIG_VERSION', '1.0.0');
|
||
|
|
define('PLUGIN_NETCONFIG_NAME', 'netconfig');
|
||
|
|
|
||
|
|
function plugin_init_netconfig() {
|
||
|
|
global $PLUGIN_HOOKS;
|
||
|
|
|
||
|
|
$PLUGIN_HOOKS['csrf_compliant']['netconfig'] = true;
|
||
|
|
$PLUGIN_HOOKS['compatible']['netconfig'] = ['glpi' => '>=11.0'];
|
||
|
|
|
||
|
|
// Integrazione menu GLPI
|
||
|
|
$PLUGIN_HOOKS['menu_toadd']['netconfig'] = [
|
||
|
|
'tools' => \PluginNetconfig\Config::class,
|
||
|
|
];
|
||
|
|
|
||
|
|
// Registrazione endpoint API per l'agent
|
||
|
|
$PLUGIN_HOOKS['add_api_endpoint']['netconfig'] = [
|
||
|
|
'/plugin/netconfig/agent' => 'PluginNetconfig\Agent\Receive',
|
||
|
|
];
|
||
|
|
|
||
|
|
// Attivazione massive actions
|
||
|
|
$PLUGIN_HOOKS['use_massive_actions']['netconfig'] = true;
|
||
|
|
|
||
|
|
// Registrazione automatica dei diritti profilo
|
||
|
|
if (class_exists('\PluginNetconfig\Config')) {
|
||
|
|
$PLUGIN_HOOKS['add_javascript']['netconfig'][] = 'netconfig.js'; // Opzionale
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function plugin_version_netconfig() {
|
||
|
|
return [
|
||
|
|
'name' => 'Network Config Backup',
|
||
|
|
'version' => PLUGIN_NETCONFIG_VERSION,
|
||
|
|
'author' => 'Your Name / Team',
|
||
|
|
'license' => 'GPL-3.0-or-later',
|
||
|
|
'homepage' => '',
|
||
|
|
'requirements' => [
|
||
|
|
'glpi' => ['min' => '11.0.0', 'max' => '11.99.99'],
|
||
|
|
'php' => ['min' => '8.2.0'],
|
||
|
|
],
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
function plugin_install_netconfig() {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function plugin_uninstall_netconfig() {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 🔹 `hook.php`
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
use DBConnection;
|
||
|
|
|
||
|
|
function plugin_netconfig_install() {
|
||
|
|
$db = DBConnection::getReadConnection();
|
||
|
|
$sql = file_get_contents(__DIR__ . '/install/mysql/install.sql');
|
||
|
|
$db->doQuery($sql);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function plugin_netconfig_uninstall() {
|
||
|
|
$db = DBConnection::getReadConnection();
|
||
|
|
$db->doQuery("DROP TABLE IF EXISTS `glpi_plugin_netconfig_configs`");
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 🔹 `composer.json`
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"name": "glpi-plugin/netconfig",
|
||
|
|
"description": "Backup e versioning configurazioni di rete per GLPI 11",
|
||
|
|
"type": "glpi-plugin",
|
||
|
|
"license": "GPL-3.0-or-later",
|
||
|
|
"minimum-stability": "stable",
|
||
|
|
"prefer-stable": true,
|
||
|
|
"require": {
|
||
|
|
"php": ">=8.2",
|
||
|
|
"symfony/diff": "^6.4",
|
||
|
|
"guzzlehttp/guzzle": "^7.8"
|
||
|
|
},
|
||
|
|
"autoload": {
|
||
|
|
"psr-4": {
|
||
|
|
"PluginNetconfig\\": "src/",
|
||
|
|
"PluginNetconfig\\Api\\": "src/Api/"
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"config": {
|
||
|
|
"optimize-autoloader": true,
|
||
|
|
"allow-plugins": {
|
||
|
|
"php-http/discovery": true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
> ⚠️ **Esegui**: `cd /path/to/glpi/plugins/netconfig && composer install --no-dev -o`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 🔹 `install/mysql/install.sql`
|
||
|
|
```sql
|
||
|
|
CREATE TABLE IF NOT EXISTS `glpi_plugin_netconfig_configs` (
|
||
|
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||
|
|
`networkdevices_id` int unsigned NOT NULL DEFAULT '0',
|
||
|
|
`config_content` mediumtext NOT NULL,
|
||
|
|
`config_hash` char(64) NOT NULL,
|
||
|
|
`is_encrypted` tinyint NOT NULL DEFAULT '1',
|
||
|
|
`created_at` datetime NOT NULL,
|
||
|
|
`users_id` int unsigned NOT NULL DEFAULT '0',
|
||
|
|
PRIMARY KEY (`id`),
|
||
|
|
KEY `networkdevices_id` (`networkdevices_id`),
|
||
|
|
KEY `created_at` (`created_at`),
|
||
|
|
KEY `config_hash` (`config_hash`)
|
||
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 🔹 `src/Config.php`
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace PluginNetconfig;
|
||
|
|
|
||
|
|
use CommonDBTM;
|
||
|
|
use CommonGLPI;
|
||
|
|
use Session;
|
||
|
|
use MassiveAction;
|
||
|
|
use DBConnection;
|
||
|
|
use Glpi\Toolbox\Encryption;
|
||
|
|
use Symfony\Component\Diff\Differ;
|
||
|
|
use Symfony\Component\Diff\Output\HtmlOutput;
|
||
|
|
use Glpi\Templating\Twig\TemplateRenderer;
|
||
|
|
|
||
|
|
class Config extends CommonDBTM
|
||
|
|
{
|
||
|
|
static $rightname = 'plugin_netconfig';
|
||
|
|
|
||
|
|
public static function getTypeName($nb = 0): string
|
||
|
|
{
|
||
|
|
return __('Network Configurations', 'netconfig');
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function getRights($interface = 'central'): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
READ => __('Read'),
|
||
|
|
UPDATE => __('Update'),
|
||
|
|
CREATE => __('Create'),
|
||
|
|
PURGE => __('Delete permanently'),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function canView(): bool { return Session::haveRight(self::$rightname, READ); }
|
||
|
|
public static function canCreate(): bool { return Session::haveRight(self::$rightname, CREATE); }
|
||
|
|
public static function canUpdate(): bool { return Session::haveRight(self::$rightname, UPDATE); }
|
||
|
|
public static function canPurge(): bool { return Session::haveRight(self::$rightname, PURGE); }
|
||
|
|
|
||
|
|
public function getTabNameForItem(CommonGLPI $item, $withtemplate = 0): string
|
||
|
|
{
|
||
|
|
if ($item->getType() === 'NetworkEquipment' && self::canView()) {
|
||
|
|
return __('Config History', 'netconfig') . ' (' . countElementsInTable(self::getTable(), ['networkdevices_id' => $item->getID()]) . ')';
|
||
|
|
}
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function displayTabContentForItem(CommonGLPI $item, $tabnum = 1, $withtemplate = 0): bool
|
||
|
|
{
|
||
|
|
if ($item->getType() !== 'NetworkEquipment') return true;
|
||
|
|
|
||
|
|
$configs = (new self())->find(
|
||
|
|
['networkdevices_id' => $item->getID()],
|
||
|
|
'created_at DESC'
|
||
|
|
);
|
||
|
|
|
||
|
|
// Calcola diff per l'ultima config (opzionale, performance-aware)
|
||
|
|
if (!empty($configs)) {
|
||
|
|
$latest = new self();
|
||
|
|
$latest->getFromDB(reset($configs)['id']);
|
||
|
|
$configs[0]['diff_html'] = $latest->getDiffFromPrevious();
|
||
|
|
}
|
||
|
|
|
||
|
|
$twig = new TemplateRenderer();
|
||
|
|
echo $twig->render('@netconfig/config_tab.html.twig', [
|
||
|
|
'configs' => $configs,
|
||
|
|
'can_export' => self::canView(),
|
||
|
|
'can_create' => self::canCreate()
|
||
|
|
]);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function getLastConfigByDevice(int $networkdevices_id): ?array
|
||
|
|
{
|
||
|
|
$db = DBConnection::getReadConnection();
|
||
|
|
$stmt = $db->prepare("SELECT * FROM " . self::getTable() . " WHERE networkdevices_id = ? ORDER BY created_at DESC LIMIT 1");
|
||
|
|
$stmt->bind_param('i', $networkdevices_id);
|
||
|
|
$stmt->execute();
|
||
|
|
$result = $stmt->get_result()->fetch_assoc();
|
||
|
|
$stmt->close();
|
||
|
|
return $result ?: null;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function saveConfig(array $data): bool
|
||
|
|
{
|
||
|
|
$config = new self();
|
||
|
|
$hash = hash('sha256', $data['content']);
|
||
|
|
$last = self::getLastConfigByDevice($data['networkdevices_id']);
|
||
|
|
|
||
|
|
if ($last && $last['config_hash'] === $hash) {
|
||
|
|
return true; // Nessuna modifica rilevata
|
||
|
|
}
|
||
|
|
|
||
|
|
$input = [
|
||
|
|
'networkdevices_id' => $data['networkdevices_id'],
|
||
|
|
'config_content' => Encryption::encrypt($data['content']),
|
||
|
|
'config_hash' => $hash,
|
||
|
|
'users_id' => $data['users_id'] ?? Session::getLoginUserID() ?: 0,
|
||
|
|
'is_encrypted' => 1,
|
||
|
|
];
|
||
|
|
|
||
|
|
return $config->add($input) !== false;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getDecryptedContent(): string
|
||
|
|
{
|
||
|
|
return $this->fields['is_encrypted']
|
||
|
|
? Encryption::decrypt($this->fields['config_content'])
|
||
|
|
: $this->fields['config_content'];
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getDiffFromPrevious(): string
|
||
|
|
{
|
||
|
|
$last = self::getLastConfigByDevice($this->fields['networkdevices_id']);
|
||
|
|
if (!$last || $last['id'] === $this->fields['id']) return '';
|
||
|
|
|
||
|
|
$prev = new self();
|
||
|
|
$prev->getFromDB($last['id']);
|
||
|
|
$differ = new Differ(new HtmlOutput());
|
||
|
|
return $differ->diff($prev->getDecryptedContent(), $this->getDecryptedContent());
|
||
|
|
}
|
||
|
|
|
||
|
|
// MASSIVE ACTIONS
|
||
|
|
public function getSpecificMassiveActions($checkitem = null): array
|
||
|
|
{
|
||
|
|
$actions = parent::getSpecificMassiveActions($checkitem);
|
||
|
|
if (self::canCreate()) {
|
||
|
|
$actions[self::class . ':BackupNow'] = __('Force backup now', 'netconfig');
|
||
|
|
}
|
||
|
|
if (self::canView()) {
|
||
|
|
$actions[self::class . ':ExportSelected'] = __('Export selected', 'netconfig');
|
||
|
|
}
|
||
|
|
return $actions;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function processMassiveActionsForOneItemtype(MassiveAction $ma, CommonDBTM $item, array $ids): void
|
||
|
|
{
|
||
|
|
switch ($ma->getAction()) {
|
||
|
|
case self::class . ':BackupNow':
|
||
|
|
foreach ($ids as $id) {
|
||
|
|
// Trigger agente o coda asincrona
|
||
|
|
// Esempio: Http::post() verso endpoint agent
|
||
|
|
$ma->itemDone($item::getType(), $id, MassiveAction::ACTION_OK);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case self::class . ':ExportSelected':
|
||
|
|
foreach ($ids as $id) {
|
||
|
|
// Redirect a export.php con ID
|
||
|
|
$ma->itemDone($item::getType(), $id, MassiveAction::ACTION_OK);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 🔹 `src/Agent/Receive.php`
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace PluginNetconfig\Agent;
|
||
|
|
|
||
|
|
use PluginNetconfig\Config;
|
||
|
|
use Glpi\Toolbox\Encryption;
|
||
|
|
|
||
|
|
class Receive
|
||
|
|
{
|
||
|
|
public function __invoke(array $input, string $method, string $route): array
|
||
|
|
{
|
||
|
|
header('Content-Type: application/json');
|
||
|
|
|
||
|
|
if ($method !== 'POST') {
|
||
|
|
http_response_code(405);
|
||
|
|
return ['status' => 'error', 'message' => 'Method not allowed'];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validazione payload
|
||
|
|
if (!isset($input['device_id'], $input['config'], $input['token'])) {
|
||
|
|
http_response_code(400);
|
||
|
|
return ['status' => 'error', 'message' => 'Missing required fields: device_id, config, token'];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validazione token (configurare in .env o config GLPI)
|
||
|
|
$validToken = $_ENV['NETCONFIG_AGENT_TOKEN'] ?? getenv('NETCONFIG_AGENT_TOKEN') ?? 'change_me_in_production';
|
||
|
|
if ($input['token'] !== $validToken) {
|
||
|
|
http_response_code(401);
|
||
|
|
return ['status' => 'error', 'message' => 'Unauthorized: invalid token'];
|
||
|
|
}
|
||
|
|
|
||
|
|
$hash = hash('sha256', $input['config']);
|
||
|
|
$last = Config::getLastConfigByDevice((int)$input['device_id']);
|
||
|
|
|
||
|
|
if ($last && $last['config_hash'] === $hash) {
|
||
|
|
return ['status' => 'ok', 'message' => 'No changes detected'];
|
||
|
|
}
|
||
|
|
|
||
|
|
$config = new Config();
|
||
|
|
$data = [
|
||
|
|
'networkdevices_id' => (int)$input['device_id'],
|
||
|
|
'config_content' => Encryption::encrypt($input['config']),
|
||
|
|
'config_hash' => $hash,
|
||
|
|
'users_id' => 0, // Agent/System
|
||
|
|
'is_encrypted' => 1,
|
||
|
|
];
|
||
|
|
|
||
|
|
if ($config->add($data) !== false) {
|
||
|
|
return ['status' => 'ok', 'message' => 'Configuration saved and versioned'];
|
||
|
|
}
|
||
|
|
|
||
|
|
http_response_code(500);
|
||
|
|
return ['status' => 'error', 'message' => 'Database insertion failed'];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 🔹 `ajax/agent_receive.php`
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
declare(strict_types=1);
|
||
|
|
include('../../../inc/includes.php');
|
||
|
|
|
||
|
|
header('Content-Type: application/json');
|
||
|
|
Session::checkLoginUser();
|
||
|
|
|
||
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
||
|
|
if (!is_array($input)) {
|
||
|
|
http_response_code(400);
|
||
|
|
echo json_encode(['status' => 'error', 'message' => 'Invalid JSON']);
|
||
|
|
exit;
|
||
|
|
}
|
||
|
|
|
||
|
|
$handler = new \PluginNetconfig\Agent\Receive();
|
||
|
|
echo json_encode($handler($input, 'POST', '/plugin/netconfig/agent'));
|
||
|
|
exit;
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 🔹 `ajax/export.php`
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
declare(strict_types=1);
|
||
|
|
include('../../../inc/includes.php');
|
||
|
|
|
||
|
|
header("Content-Type: application/octet-stream");
|
||
|
|
header("Pragma: no-cache");
|
||
|
|
Html::header_nocache();
|
||
|
|
|
||
|
|
$id = (int)($_GET['id'] ?? 0);
|
||
|
|
if (!$id || !\PluginNetconfig\Config::canView()) {
|
||
|
|
http_response_code(403);
|
||
|
|
exit('Access denied');
|
||
|
|
}
|
||
|
|
|
||
|
|
$config = new \PluginNetconfig\Config();
|
||
|
|
if (!$config->getFromDB($id)) {
|
||
|
|
http_response_code(404);
|
||
|
|
exit('Configuration not found');
|
||
|
|
}
|
||
|
|
|
||
|
|
$content = $config->getDecryptedContent();
|
||
|
|
|
||
|
|
// Opzionale: mascheramento credenziali sensibili
|
||
|
|
$content = preg_replace('/(?<=password\s|secret\s|enable\s|community\s)[^\s\r\n]+/i', '***REDACTED***', $content);
|
||
|
|
|
||
|
|
header('Content-Disposition: attachment; filename="netconfig_' . $id . '_' . date('Ymd_His') . '.txt"');
|
||
|
|
echo $content;
|
||
|
|
exit;
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 🔹 `templates/config_tab.html.twig`
|
||
|
|
```twig
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-body">
|
||
|
|
<table class="tab_cadre_fixehov">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>{{ __('Date') }}</th>
|
||
|
|
<th>{{ __('Hash') }}</th>
|
||
|
|
<th>{{ __('User') }}</th>
|
||
|
|
<th>{{ __('Actions') }}</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{% for config in configs %}
|
||
|
|
<tr class="tab_bg_1">
|
||
|
|
<td>{{ config.created_at }}</td>
|
||
|
|
<td><code>{{ config.config_hash|slice(0, 16) }}...</code></td>
|
||
|
|
<td>{{ config.users_id > 0 ? config.users_id|getUserName : __('System') }}</td>
|
||
|
|
<td>
|
||
|
|
<div class="btn-group btn-group-sm">
|
||
|
|
{% if can_export %}
|
||
|
|
<a href="{{ path('/plugins/netconfig/ajax/export.php') }}?id={{ config.id }}"
|
||
|
|
class="btn btn-outline-primary"
|
||
|
|
title="{{ __('Export as text file') }}">
|
||
|
|
<i class="fas fa-download"></i>
|
||
|
|
</a>
|
||
|
|
{% endif %}
|
||
|
|
{% if config.diff_html is defined and config.diff_html is not empty %}
|
||
|
|
<button class="btn btn-outline-info"
|
||
|
|
onclick="GlpiDisplayDialog('diff_{{ config.id }}', '{{ __('Configuration Diff') }}', '{{ config.diff_html|escape('js') }}')"
|
||
|
|
title="{{ __('View changes from previous version') }}">
|
||
|
|
<i class="fas fa-code-compare"></i>
|
||
|
|
</button>
|
||
|
|
{% endif %}
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{% else %}
|
||
|
|
<tr class="tab_bg_1">
|
||
|
|
<td colspan="4" class="center text-muted">
|
||
|
|
{{ __('No configuration saved yet') }}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{% endfor %}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
|
||
|
|
{% if can_create %}
|
||
|
|
<div class="mt-3 text-end">
|
||
|
|
<button class="btn btn-primary" onclick="Netconfig.triggerBackup({{ item.fields.id }})">
|
||
|
|
<i class="fas fa-sync-alt"></i> {{ __('Retrieve configuration now') }}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
{% endif %}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
var Netconfig = {
|
||
|
|
triggerBackup: function(deviceId) {
|
||
|
|
$.ajax({
|
||
|
|
url: CFG_GLPI.root_doc + '/plugins/netconfig/ajax/trigger_backup.php',
|
||
|
|
method: 'POST',
|
||
|
|
data: {
|
||
|
|
devices_id: deviceId,
|
||
|
|
_glpi_csrf_token: CFG_GLPI.csrf_token
|
||
|
|
},
|
||
|
|
success: function(response) {
|
||
|
|
if (response.status === 'ok') {
|
||
|
|
addMessageAfterRedirect('Backup triggered successfully', false, INFO);
|
||
|
|
setTimeout(() => location.reload(), 2000);
|
||
|
|
} else {
|
||
|
|
addMessageAfterRedirect(response.message || 'Error triggering backup', true, ERROR);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
error: function() {
|
||
|
|
addMessageAfterRedirect('AJAX error', true, ERROR);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
</script>
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🐍 4. Script Python per GLPI Agent (`netconfig_backup.py`)
|
||
|
|
|
||
|
|
> Posiziona in `/usr/share/glpi-agent/plugins/` o cartella task personalizzate.
|
||
|
|
|
||
|
|
```python
|
||
|
|
#!/usr/bin/env python3
|
||
|
|
# -*- coding: utf-8 -*-
|
||
|
|
"""
|
||
|
|
GLPI Agent Task: netconfig_backup
|
||
|
|
Recupera configurazione da apparati di rete e invia a GLPI plugin netconfig
|
||
|
|
Requisiti: netmiko, requests, pyyaml
|
||
|
|
Install: pip3 install netmiko requests pyyaml
|
||
|
|
"""
|
||
|
|
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
import json
|
||
|
|
import hashlib
|
||
|
|
import logging
|
||
|
|
import requests
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from typing import Optional, Dict, List
|
||
|
|
from netmiko import ConnectHandler, NetmikoTimeoutException, NetmikoAuthenticationException
|
||
|
|
import yaml
|
||
|
|
|
||
|
|
# Configurazione logging
|
||
|
|
logging.basicConfig(
|
||
|
|
level=logging.INFO,
|
||
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||
|
|
handlers=[
|
||
|
|
logging.FileHandler('/var/log/glpi_agent_netconfig.log'),
|
||
|
|
logging.StreamHandler(sys.stdout)
|
||
|
|
]
|
||
|
|
)
|
||
|
|
logger = logging.getLogger('netconfig_backup')
|
||
|
|
|
||
|
|
# Configurazione da variabili d'ambiente o file YAML esterno
|
||
|
|
CONFIG_FILE = os.getenv('NETCONFIG_CONFIG', '/etc/glpi-agent/netconfig_devices.yaml')
|
||
|
|
GLPI_URL = os.getenv('GLPI_URL', 'http://localhost/glpi')
|
||
|
|
GLPI_APP_TOKEN = os.getenv('GLPI_APP_TOKEN', '')
|
||
|
|
AGENT_TOKEN = os.getenv('NETCONFIG_AGENT_TOKEN', 'change_me_in_production')
|
||
|
|
TIMEOUT = int(os.getenv('NETCONFIG_TIMEOUT', '30'))
|
||
|
|
RETRIES = int(os.getenv('NETCONFIG_RETRIES', '2'))
|
||
|
|
SSL_VERIFY = os.getenv('SSL_VERIFY', 'true').lower() == 'true'
|
||
|
|
|
||
|
|
|
||
|
|
def load_devices_config(path: str) -> List[Dict]:
|
||
|
|
"""Carica configurazione dispositivi da YAML"""
|
||
|
|
if not os.path.exists(path):
|
||
|
|
logger.error(f"Config file not found: {path}")
|
||
|
|
return []
|
||
|
|
|
||
|
|
try:
|
||
|
|
with open(path, 'r') as f:
|
||
|
|
data = yaml.safe_load(f)
|
||
|
|
return data.get('devices', [])
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Error loading config: {e}")
|
||
|
|
return []
|
||
|
|
|
||
|
|
|
||
|
|
def get_device_connection_params(device: Dict) -> Dict:
|
||
|
|
"""Prepara parametri di connessione per netmiko"""
|
||
|
|
device_type_map = {
|
||
|
|
'cisco_ios': 'cisco_ios',
|
||
|
|
'cisco_nxos': 'cisco_nxos',
|
||
|
|
'hp_comware': 'hp_comware',
|
||
|
|
'juniper_junos': 'juniper_junos',
|
||
|
|
'arista_eos': 'arista_eos',
|
||
|
|
'paloalto_panos': 'paloalto_panos',
|
||
|
|
'fortinet': 'fortinet',
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
'device_type': device_type_map.get(device['platform'], 'cisco_ios'),
|
||
|
|
'host': device['ip'],
|
||
|
|
'username': device.get('username', os.getenv('NETCONFIG_DEFAULT_USER')),
|
||
|
|
'password': device.get('password', os.getenv('NETCONFIG_DEFAULT_PASS')),
|
||
|
|
'secret': device.get('enable_password', os.getenv('NETCONFIG_ENABLE_PASS')),
|
||
|
|
'port': device.get('port', 22),
|
||
|
|
'timeout': TIMEOUT,
|
||
|
|
'session_log': None,
|
||
|
|
'global_delay_factor': device.get('delay_factor', 1),
|
||
|
|
'auth_timeout': 10,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def fetch_config(device: Dict) -> Optional[str]:
|
||
|
|
"""Recupera configurazione via SSH con netmiko"""
|
||
|
|
params = get_device_connection_params(device)
|
||
|
|
|
||
|
|
for attempt in range(RETRIES + 1):
|
||
|
|
try:
|
||
|
|
logger.info(f"Connecting to {device['name']} ({params['host']}) [attempt {attempt+1}/{RETRIES+1}]")
|
||
|
|
connection = ConnectHandler(**params)
|
||
|
|
|
||
|
|
# Abilita modalità enable se necessaria
|
||
|
|
if params.get('secret'):
|
||
|
|
connection.enable()
|
||
|
|
|
||
|
|
# Comando di show config (personalizzabile per piattaforma)
|
||
|
|
command = device.get('command', 'show running-config')
|
||
|
|
config = connection.send_command(command, expect_string=r'#|\$', max_loops=150)
|
||
|
|
|
||
|
|
connection.disconnect()
|
||
|
|
|
||
|
|
# Pulizia output da caratteri di controllo e spazi finali
|
||
|
|
config = config.strip()
|
||
|
|
config = '\n'.join(line.rstrip() for line in config.splitlines())
|
||
|
|
|
||
|
|
logger.info(f"✓ Config fetched successfully for {device['name']} ({len(config)} bytes)")
|
||
|
|
return config
|
||
|
|
|
||
|
|
except NetmikoAuthenticationException as e:
|
||
|
|
logger.error(f"✗ Auth failed for {device['name']}: {e}")
|
||
|
|
break # Non ritentare se auth fallisce
|
||
|
|
except NetmikoTimeoutException as e:
|
||
|
|
logger.warning(f"⚠ Timeout for {device['name']}: {e}")
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"✗ Unexpected error for {device['name']}: {type(e).__name__}: {e}")
|
||
|
|
if attempt < RETRIES:
|
||
|
|
logger.info(f"Retrying in 5s...")
|
||
|
|
import time; time.sleep(5)
|
||
|
|
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def send_to_glpi(device_id: int, config: str) -> bool:
|
||
|
|
"""Invia configurazione a GLPI plugin endpoint"""
|
||
|
|
url = f"{GLPI_URL}/plugins/netconfig/ajax/agent_receive.php"
|
||
|
|
|
||
|
|
payload = {
|
||
|
|
'device_id': device_id,
|
||
|
|
'config': config,
|
||
|
|
'token': AGENT_TOKEN,
|
||
|
|
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||
|
|
'agent_version': '1.0.0',
|
||
|
|
'agent_hostname': os.getenv('HOSTNAME', 'unknown')
|
||
|
|
}
|
||
|
|
|
||
|
|
headers = {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
}
|
||
|
|
if GLPI_APP_TOKEN:
|
||
|
|
headers['App-Token'] = GLPI_APP_TOKEN
|
||
|
|
|
||
|
|
try:
|
||
|
|
response = requests.post(
|
||
|
|
url,
|
||
|
|
json=payload,
|
||
|
|
headers=headers,
|
||
|
|
timeout=30,
|
||
|
|
verify=SSL_VERIFY
|
||
|
|
)
|
||
|
|
|
||
|
|
if response.status_code == 200:
|
||
|
|
result = response.json()
|
||
|
|
logger.info(f"GLPI response: {result.get('message', 'OK')}")
|
||
|
|
return result.get('status') == 'ok'
|
||
|
|
else:
|
||
|
|
logger.error(f"GLPI HTTP {response.status_code}: {response.text[:200]}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
except requests.RequestException as e:
|
||
|
|
logger.error(f"Request to GLPI failed: {type(e).__name__}: {e}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def calculate_hash(config: str) -> str:
|
||
|
|
"""Calcola SHA256 della configurazione"""
|
||
|
|
return hashlib.sha256(config.encode('utf-8')).hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
def run_task():
|
||
|
|
"""Entry point della task"""
|
||
|
|
logger.info("=== NetConfig Backup Task Started ===")
|
||
|
|
|
||
|
|
devices = load_devices_config(CONFIG_FILE)
|
||
|
|
if not devices:
|
||
|
|
logger.warning("No devices configured, exiting")
|
||
|
|
return
|
||
|
|
|
||
|
|
success_count = 0
|
||
|
|
fail_count = 0
|
||
|
|
|
||
|
|
for device in devices:
|
||
|
|
try:
|
||
|
|
device_name = device.get('name', 'unknown')
|
||
|
|
device_id = device.get('glpi_id') # ID in glpi_networkdevices
|
||
|
|
|
||
|
|
if not device_id:
|
||
|
|
logger.warning(f"⚠ Skipping {device_name}: missing glpi_id")
|
||
|
|
continue
|
||
|
|
|
||
|
|
config = fetch_config(device)
|
||
|
|
if not config:
|
||
|
|
logger.warning(f"✗ Failed to fetch config for {device_name}")
|
||
|
|
fail_count += 1
|
||
|
|
continue
|
||
|
|
|
||
|
|
if send_to_glpi(device_id, config):
|
||
|
|
success_count += 1
|
||
|
|
logger.info(f"✓ {device_name} - Config saved")
|
||
|
|
else:
|
||
|
|
logger.error(f"✗ {device_name} - Failed to save to GLPI")
|
||
|
|
fail_count += 1
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.exception(f"✗ Unhandled error for device {device.get('name')}: {e}")
|
||
|
|
fail_count += 1
|
||
|
|
|
||
|
|
logger.info(f"=== Task Completed: {success_count} OK, {fail_count} FAILED / {len(devices)} total ===")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
run_task()
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 🔹 File di configurazione dispositivi: `/etc/glpi-agent/netconfig_devices.yaml`
|
||
|
|
```yaml
|
||
|
|
# Esempio configurazione dispositivi per netconfig_backup.py
|
||
|
|
# Permessi file: chmod 600 /etc/glpi-agent/netconfig_devices.yaml
|
||
|
|
# Suggerimento: usare vault esterno per password (es. HashiCorp Vault, AWS Secrets Manager)
|
||
|
|
|
||
|
|
devices:
|
||
|
|
- name: "SW-Core-01"
|
||
|
|
ip: "10.0.0.1"
|
||
|
|
platform: "cisco_ios"
|
||
|
|
glpi_id: 123 # ID in glpi_networkdevices
|
||
|
|
username: "backup_user"
|
||
|
|
# password: "xxx" # ⚠️ Non inserire password in chiaro! Usa variabili d'ambiente
|
||
|
|
enable_password: "enable_secret"
|
||
|
|
command: "show running-config"
|
||
|
|
delay_factor: 2
|
||
|
|
timeout: 45
|
||
|
|
|
||
|
|
- name: "SW-Access-05"
|
||
|
|
ip: "10.0.0.5"
|
||
|
|
platform: "hp_comware"
|
||
|
|
glpi_id: 124
|
||
|
|
username: "admin"
|
||
|
|
command: "display current-configuration"
|
||
|
|
port: 2222
|
||
|
|
|
||
|
|
- name: "FW-Edge-01"
|
||
|
|
ip: "10.0.0.254"
|
||
|
|
platform: "paloalto_panos"
|
||
|
|
glpi_id: 125
|
||
|
|
username: "api_user"
|
||
|
|
command: "show config running"
|
||
|
|
```
|
||
|
|
|
||
|
|
> 🔐 **Best Practice per le credenziali**:
|
||
|
|
> ```bash
|
||
|
|
> # In /etc/environment o systemd service file:
|
||
|
|
> export NETCONFIG_DEFAULT_USER="backup_user"
|
||
|
|
> export NETCONFIG_DEFAULT_PASS="$(cat /run/secrets/netconfig_pass)"
|
||
|
|
> export NETCONFIG_ENABLE_PASS="$(vault kv get -field=enable netconfig)"
|
||
|
|
> ```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ⏱️ 5. Configurazione Scheduler
|
||
|
|
|
||
|
|
### 🔸 Opzione A: Cron (semplice)
|
||
|
|
```bash
|
||
|
|
# /etc/cron.d/glpi-netconfig
|
||
|
|
# Esegui backup ogni 6 ore (02:00, 08:00, 14:00, 20:00)
|
||
|
|
SHELL=/bin/bash
|
||
|
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
|
||
|
|
|
||
|
|
0 2,8,14,20 * * * glpi-agent /usr/bin/python3 /usr/share/glpi-agent/plugins/netconfig_backup.py >> /var/log/glpi_netconfig_cron.log 2>&1
|
||
|
|
```
|
||
|
|
|
||
|
|
### 🔸 Opzione B: Supervisor (controllo processi)
|
||
|
|
```ini
|
||
|
|
# /etc/supervisor/conf.d/glpi-netconfig.conf
|
||
|
|
[program:glpi-netconfig]
|
||
|
|
command=/usr/bin/python3 /usr/share/glpi-agent/plugins/netconfig_backup.py
|
||
|
|
directory=/usr/share/glpi-agent/plugins
|
||
|
|
user=glpi-agent
|
||
|
|
autostart=false
|
||
|
|
autorestart=false
|
||
|
|
startsecs=0
|
||
|
|
stdout_logfile=/var/log/glpi_netconfig_supervisor.log
|
||
|
|
stderr_logfile=/var/log/glpi_netconfig_supervisor_err.log
|
||
|
|
environment=NETCONFIG_CONFIG="/etc/glpi-agent/netconfig_devices.yaml",GLPI_URL="https://glpi.example.com",NETCONFIG_AGENT_TOKEN="your_secure_token_here"
|
||
|
|
```
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Comandi utili
|
||
|
|
sudo supervisorctl reread
|
||
|
|
sudo supervisorctl update
|
||
|
|
sudo supervisorctl start glpi-netconfig
|
||
|
|
sudo tail -f /var/log/glpi_netconfig_supervisor.log
|
||
|
|
```
|
||
|
|
|
||
|
|
### 🔸 Opzione C: Systemd Timer (consigliato per sistemi moderni)
|
||
|
|
```ini
|
||
|
|
# /etc/systemd/system/glpi-netconfig.service
|
||
|
|
[Unit]
|
||
|
|
Description=GLPI NetConfig Backup Task
|
||
|
|
After=network.target
|
||
|
|
|
||
|
|
[Service]
|
||
|
|
Type=oneshot
|
||
|
|
User=glpi-agent
|
||
|
|
ExecStart=/usr/bin/python3 /usr/share/glpi-agent/plugins/netconfig_backup.py
|
||
|
|
Environment="NETCONFIG_CONFIG=/etc/glpi-agent/netconfig_devices.yaml"
|
||
|
|
Environment="GLPI_URL=https://glpi.example.com"
|
||
|
|
Environment="NETCONFIG_AGENT_TOKEN=your_secure_token_here"
|
||
|
|
Environment="SSL_VERIFY=true"
|
||
|
|
StandardOutput=journal
|
||
|
|
StandardError=journal
|
||
|
|
```
|
||
|
|
|
||
|
|
```ini
|
||
|
|
# /etc/systemd/system/glpi-netconfig.timer
|
||
|
|
[Unit]
|
||
|
|
Description=Run GLPI NetConfig Backup every 6 hours
|
||
|
|
Requires=glpi-netconfig.service
|
||
|
|
|
||
|
|
[Timer]
|
||
|
|
OnCalendar=*-*-* 02/6:00:00
|
||
|
|
Persistent=true
|
||
|
|
RandomizedDelaySec=300
|
||
|
|
Unit=glpi-netconfig.service
|
||
|
|
|
||
|
|
[Install]
|
||
|
|
WantedBy=timers.target
|
||
|
|
```
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Attivazione
|
||
|
|
sudo systemctl daemon-reload
|
||
|
|
sudo systemctl enable --now glpi-netconfig.timer
|
||
|
|
|
||
|
|
# Verifica
|
||
|
|
systemctl list-timers | grep netconfig
|
||
|
|
journalctl -u glpi-netconfig.service -f
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🔌 6. Client API REST GLPI 11 (`src/Api/GlpiApiClient.php`)
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace PluginNetconfig\Api;
|
||
|
|
|
||
|
|
use GuzzleHttp\Client;
|
||
|
|
use GuzzleHttp\Exception\RequestException;
|
||
|
|
use GuzzleHttp\Exception\GuzzleException;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Client per API REST GLPI 11
|
||
|
|
* Supporta App-Token + Session-Token o User-Token
|
||
|
|
* Gestisce automaticamente refresh sessione su 401
|
||
|
|
*/
|
||
|
|
class GlpiApiClient
|
||
|
|
{
|
||
|
|
private string $baseUrl;
|
||
|
|
private string $appToken;
|
||
|
|
private ?string $sessionToken = null;
|
||
|
|
private Client $httpClient;
|
||
|
|
|
||
|
|
public function __construct(string $baseUrl, string $appToken, ?string $userToken = null)
|
||
|
|
{
|
||
|
|
$this->baseUrl = rtrim($baseUrl, '/');
|
||
|
|
$this->appToken = $appToken;
|
||
|
|
|
||
|
|
$this->httpClient = new Client([
|
||
|
|
'base_uri' => $this->baseUrl . '/apirest.php/',
|
||
|
|
'timeout' => 30.0,
|
||
|
|
'headers' => [
|
||
|
|
'App-Token' => $this->appToken,
|
||
|
|
'Content-Type' => 'application/json',
|
||
|
|
'Accept' => 'application/json',
|
||
|
|
],
|
||
|
|
]);
|
||
|
|
|
||
|
|
if ($userToken) {
|
||
|
|
$this->loginWithUserToken($userToken);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Login con credenziali utente (username + password)
|
||
|
|
* @deprecated Usare loginWithUserToken per maggiore sicurezza
|
||
|
|
*/
|
||
|
|
public function login(string $username, string $password): bool
|
||
|
|
{
|
||
|
|
try {
|
||
|
|
$response = $this->httpClient->post('initSession', [
|
||
|
|
'auth' => [$username, $password],
|
||
|
|
'http_errors' => true,
|
||
|
|
]);
|
||
|
|
$data = json_decode($response->getBody(), true);
|
||
|
|
$this->sessionToken = $data['session_token'] ?? null;
|
||
|
|
return $this->sessionToken !== null;
|
||
|
|
} catch (RequestException $e) {
|
||
|
|
error_log("GLPI API login failed: " . $e->getMessage());
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Login con user token (generato in Profilo > API, più sicuro)
|
||
|
|
*/
|
||
|
|
public function loginWithUserToken(string $userToken): bool
|
||
|
|
{
|
||
|
|
try {
|
||
|
|
$response = $this->httpClient->post('initSession', [
|
||
|
|
'headers' => ['User-Token' => $userToken],
|
||
|
|
'http_errors' => true,
|
||
|
|
]);
|
||
|
|
$data = json_decode($response->getBody(), true);
|
||
|
|
$this->sessionToken = $data['session_token'] ?? null;
|
||
|
|
return $this->sessionToken !== null;
|
||
|
|
} catch (RequestException $e) {
|
||
|
|
error_log("GLPI API user-token login failed: " . $e->getMessage());
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Logout e distruzione sessione
|
||
|
|
*/
|
||
|
|
public function logout(): void
|
||
|
|
{
|
||
|
|
if ($this->sessionToken) {
|
||
|
|
try {
|
||
|
|
$this->httpClient->delete('killSession', [
|
||
|
|
'headers' => ['Session-Token' => $this->sessionToken],
|
||
|
|
'http_errors' => false, // Ignora errori in logout
|
||
|
|
]);
|
||
|
|
} catch (\Throwable $e) {
|
||
|
|
// Log silenzioso
|
||
|
|
}
|
||
|
|
$this->sessionToken = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Esegue una chiamata API con retry automatico su token scaduto
|
||
|
|
*/
|
||
|
|
private function request(string $method, string $endpoint, array $data = [], bool $retry = true): ?array
|
||
|
|
{
|
||
|
|
$headers = $this->sessionToken ? ['Session-Token' => $this->sessionToken] : [];
|
||
|
|
|
||
|
|
try {
|
||
|
|
$response = $this->httpClient->request($method, $endpoint, [
|
||
|
|
'headers' => $headers,
|
||
|
|
'json' => $data,
|
||
|
|
'http_errors' => true,
|
||
|
|
]);
|
||
|
|
return json_decode($response->getBody(), true);
|
||
|
|
} catch (RequestException $e) {
|
||
|
|
// Se token scaduto (401) e retry abilitato, tenta re-login
|
||
|
|
if ($retry && $e->getResponse()?->getStatusCode() === 401 && $this->sessionToken) {
|
||
|
|
$this->logout();
|
||
|
|
// Tentativo di re-login con user token se disponibile
|
||
|
|
$userToken = getenv('GLPI_USER_TOKEN');
|
||
|
|
if ($userToken && $this->loginWithUserToken($userToken)) {
|
||
|
|
return $this->request($method, $endpoint, $data, false); // retry una volta sola
|
||
|
|
}
|
||
|
|
throw new \RuntimeException("GLPI API session expired and re-authentication failed.");
|
||
|
|
}
|
||
|
|
throw $e;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Ottieni dispositivo di rete per ID
|
||
|
|
*/
|
||
|
|
public function getNetworkEquipment(int $id): ?array
|
||
|
|
{
|
||
|
|
$result = $this->request('GET', "NetworkEquipment/$id");
|
||
|
|
return $result && !isset($result[0]['error']) ? $result : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Cerca dispositivi per nome/IP (per mapping agent -> GLPI ID)
|
||
|
|
*/
|
||
|
|
public function searchNetworkEquipment(string $nameOrIp): array
|
||
|
|
{
|
||
|
|
$query = [
|
||
|
|
'criteria' => [
|
||
|
|
['field' => 1, 'searchtype' => 'contains', 'value' => $nameOrIp], // name
|
||
|
|
['OR' => [
|
||
|
|
['field' => 2, 'searchtype' => 'contains', 'value' => $nameOrIp], // ip
|
||
|
|
]],
|
||
|
|
],
|
||
|
|
'forcedisplay' => [1, 2, 3], // id, name, ip
|
||
|
|
];
|
||
|
|
|
||
|
|
$result = $this->request('GET', 'search/NetworkEquipment', $query);
|
||
|
|
return $result['data'] ?? [];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Invia configurazione al plugin (endpoint personalizzato)
|
||
|
|
*/
|
||
|
|
public function sendConfig(int $deviceId, string $config, string $agentToken): bool
|
||
|
|
{
|
||
|
|
$payload = [
|
||
|
|
'device_id' => $deviceId,
|
||
|
|
'config' => $config,
|
||
|
|
'token' => $agentToken,
|
||
|
|
'timestamp' => date('c'),
|
||
|
|
];
|
||
|
|
|
||
|
|
$result = $this->request('POST', 'plugin/netconfig/agent', $payload);
|
||
|
|
return ($result['status'] ?? '') === 'ok';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Utility: ottieni lista dispositivi con ultima config
|
||
|
|
*/
|
||
|
|
public function getDevicesWithLastConfig(): array
|
||
|
|
{
|
||
|
|
// Esempio: join tra NetworkEquipment e plugin_netconfig_configs
|
||
|
|
// Implementabile via search API o query diretta se necessario
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
public function __destruct()
|
||
|
|
{
|
||
|
|
$this->logout();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🚀 7. Installazione Passo-Passo
|
||
|
|
|
||
|
|
### ✅ Prerequisiti
|
||
|
|
- GLPI 11.0.6+ installato e funzionante
|
||
|
|
- PHP 8.2+ con estensioni: `curl`, `json`, `mbstring`, `openssl`, `zip`
|
||
|
|
- Composer 2.x installato globalmente
|
||
|
|
- Accesso SSH al server GLPI e all'agent
|
||
|
|
- MySQL/MariaDB con privilegi di creazione tabelle
|
||
|
|
|
||
|
|
### 🔧 Procedura
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# 1. Posiziona i file del plugin
|
||
|
|
cd /var/www/glpi/plugins
|
||
|
|
git clone <repo> netconfig
|
||
|
|
# OPPURE copia manualmente i file nella struttura indicata
|
||
|
|
|
||
|
|
# 2. Installa dipendenze Composer
|
||
|
|
cd netconfig
|
||
|
|
composer install --no-dev -o
|
||
|
|
|
||
|
|
# 3. Imposta permessi corretti
|
||
|
|
chown -R www-data:www-data /var/www/glpi/plugins/netconfig
|
||
|
|
chmod -R 755 /var/www/glpi/plugins/netconfig
|
||
|
|
|
||
|
|
# 4. Installa il plugin da UI GLPI
|
||
|
|
# Vai su: Configurazione > Plugin > Network Config Backup > Installa > Attiva
|
||
|
|
|
||
|
|
# 5. Configura diritti profilo
|
||
|
|
# Amministrazione > Profili > [Seleziona profilo] > Tab "Network Configurations"
|
||
|
|
# Spunta: Read, Create, Update, Delete permanently come necessario
|
||
|
|
|
||
|
|
# 6. Configura l'agent (su server separato o stesso host)
|
||
|
|
# - Copia netconfig_backup.py in /usr/share/glpi-agent/plugins/
|
||
|
|
# - Crea /etc/glpi-agent/netconfig_devices.yaml con i tuoi dispositivi
|
||
|
|
# - Imposta variabili d'ambiente in systemd/supervisor
|
||
|
|
|
||
|
|
# 7. Testa manualmente l'agent
|
||
|
|
sudo -u glpi-agent python3 /usr/share/glpi-agent/plugins/netconfig_backup.py
|
||
|
|
|
||
|
|
# 8. Verifica in GLPI
|
||
|
|
# Apri un apparato: Rete > Apparati di rete > [Switch] > Tab "Config History"
|
||
|
|
# Dovresti vedere le configurazioni salvate con hash e data
|
||
|
|
```
|
||
|
|
|
||
|
|
### 🧪 Test Rapido con cURL
|
||
|
|
```bash
|
||
|
|
# Simula invio da agent (sostituisci valori reali)
|
||
|
|
curl -X POST http://localhost/glpi/plugins/netconfig/ajax/agent_receive.php \
|
||
|
|
-H "Content-Type: application/json" \
|
||
|
|
-d '{
|
||
|
|
"device_id": 123,
|
||
|
|
"config": "!\nhostname TEST-SW\ninterface Vlan1\n ip address 10.0.0.1 255.255.255.0\nend",
|
||
|
|
"token": "your_agent_token_here"
|
||
|
|
}'
|
||
|
|
|
||
|
|
# Risposta attesa:
|
||
|
|
# {"status":"ok","message":"Configuration saved and versioned"}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ⚠️ 8. Risoluzione Problemi Composer
|
||
|
|
|
||
|
|
### ❗ Il tuo output `composer diagnose`:
|
||
|
|
```
|
||
|
|
Checking composer.json: WARNING
|
||
|
|
No license specified, it is recommended to do so. For closed-source software you may use "proprietary" as license.
|
||
|
|
...
|
||
|
|
Checking Composer and its dependencies for vulnerabilities: WARNING
|
||
|
|
Could not find Composer's installed.json, this must be a non-standard Composer installation.
|
||
|
|
```
|
||
|
|
|
||
|
|
### ✅ Questi warning sono **NON bloccanti**:
|
||
|
|
| Warning | Impatto | Soluzione |
|
||
|
|
|---------|---------|-----------|
|
||
|
|
| `No license specified` | Nessuno (solo informativo) | Aggiunto `"license": "GPL-3.0-or-later"` in `composer.json` |
|
||
|
|
| `Could not find installed.json` | Nessuno se `composer install` funziona | Tipico in installazioni non-standard (es. GLPI in container). Ignorabile se il plugin si installa correttamente. |
|
||
|
|
|
||
|
|
### 🔍 Se `symfony/diff` non viene trovato:
|
||
|
|
```bash
|
||
|
|
# 1. Aggiorna Composer
|
||
|
|
composer self-update
|
||
|
|
|
||
|
|
# 2. Pulisci cache
|
||
|
|
composer clear-cache
|
||
|
|
|
||
|
|
# 3. Verifica connessione a Packagist
|
||
|
|
composer diagnose | grep packagist
|
||
|
|
|
||
|
|
# 4. Forza reinstall con verbose
|
||
|
|
composer install -vvv --no-dev -o
|
||
|
|
|
||
|
|
# 5. Se ancora fallisce, specifica versione esplicita in composer.json:
|
||
|
|
# "symfony/diff": "6.4.3"
|
||
|
|
```
|
||
|
|
|
||
|
|
### 🐳 Se usi Docker/Container:
|
||
|
|
```dockerfile
|
||
|
|
# Assicurati che il volume composer cache sia montato correttamente
|
||
|
|
# E che l'utente dentro il container abbia permessi di scrittura
|
||
|
|
RUN composer install --no-dev -o --ignore-platform-reqs
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🔐 9. Sicurezza e Best Practice
|
||
|
|
|
||
|
|
### ✅ Checklist Sicurezza
|
||
|
|
- [ ] **Token agente**: Usa `openssl_rand_pseudo_bytes(32)` per generare `NETCONFIG_AGENT_TOKEN`
|
||
|
|
- [ ] **Credenziali di rete**: Mai hardcodate. Usa:
|
||
|
|
- Variabili d'ambiente protette
|
||
|
|
- HashiCorp Vault / AWS Secrets Manager
|
||
|
|
- File con permessi `600` leggibili solo dall'utente agent
|
||
|
|
- [ ] **Cifratura**: Il plugin usa `Glpi\Toolbox\Encryption` (AES-256-GCM). Verifica che `GLPI_CONFIG_DIR/config_db.php` contenga `crypt_key` valida.
|
||
|
|
- [ ] **Export**: Il file `ajax/export.php` maschera automaticamente password/secret. Personalizza la regex in base alle tue policy.
|
||
|
|
- [ ] **API REST**: Usa sempre `User-Token` invece di username/password. Genera token in *Amministrazione > Utenti > [Utente] > API*.
|
||
|
|
- [ ] **Log**: Configura `log_level` in GLPI e ruota i log dell'agent (`logrotate`).
|
||
|
|
|
||
|
|
### 🔐 Generazione Token Sicuri
|
||
|
|
```bash
|
||
|
|
# Token agente (32 byte hex)
|
||
|
|
openssl rand -hex 32
|
||
|
|
# Esempio output: a1b2c3d4e5f6...
|
||
|
|
|
||
|
|
# User-Token GLPI (generato da UI, ma verificabile via CLI)
|
||
|
|
php /var/www/glpi/bin/console glpi:api:token --user=backup_agent
|
||
|
|
```
|
||
|
|
|
||
|
|
### 🛡️ Hardening Agent Python
|
||
|
|
```python
|
||
|
|
# Aggiungi in netconfig_backup.py, prima di fetch_config():
|
||
|
|
import secrets, sys
|
||
|
|
|
||
|
|
# Verifica integrità script (opzionale, per ambienti ad alta sicurezza)
|
||
|
|
EXPECTED_HASH = os.getenv('NETCONFIG_SCRIPT_HASH')
|
||
|
|
if EXPECTED_HASH:
|
||
|
|
import hashlib
|
||
|
|
with open(__file__, 'rb') as f:
|
||
|
|
actual_hash = hashlib.sha256(f.read()).hexdigest()
|
||
|
|
if actual_hash != EXPECTED_HASH:
|
||
|
|
logger.critical("Script integrity check failed!")
|
||
|
|
sys.exit(1)
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ✅ 10. Checklist Finale di Deploy
|
||
|
|
|
||
|
|
| Step | Comando / Azione | Verifica |
|
||
|
|
|------|-----------------|----------|
|
||
|
|
| 1 | `composer install -o --no-dev` in `plugins/netconfig/` | ✅ Nessun errore, cartella `vendor/` presente |
|
||
|
|
| 2 | Installa plugin da UI GLPI > Plugin | ✅ Stato: "Installato e attivato" |
|
||
|
|
| 3 | Assegna diritti in *Amministrazione > Profili* | ✅ Tab `Network Configurations` visibile con permessi |
|
||
|
|
| 4 | Copia script Python in `/usr/share/glpi-agent/plugins/` | ✅ `chmod +x`, proprietario `glpi-agent`, permessi `755` |
|
||
|
|
| 5 | Configura `/etc/glpi-agent/netconfig_devices.yaml` | ✅ Permessi `600`, nessun dato sensibile in chiaro |
|
||
|
|
| 6 | Imposta variabili d'ambiente in systemd/supervisor | ✅ `echo $NETCONFIG_AGENT_TOKEN` restituisce valore |
|
||
|
|
| 7 | Testa manualmente: `sudo -u glpi-agent python3 netconfig_backup.py` | ✅ Log mostrano "Config saved" per dispositivi di test |
|
||
|
|
| 8 | Attiva timer/scheduler | ✅ `systemctl list-timers` o `crontab -l` mostra job attivo |
|
||
|
|
| 9 | Verifica in GLPI: apri un NetworkEquipment > tab *Config History* | ✅ Configurazioni appaiono con hash, data, utente |
|
||
|
|
| 10 | Testa export e diff | ✅ Pulsante "Export" scarica file, diff evidenzia modifiche |
|
||
|
|
| 11 | Testa massive action "Force backup now" | ✅ Triggera backup immediato per dispositivi selezionati |
|
||
|
|
| 12 | Verifica cifratura DB | ✅ Campo `config_content` in DB è cifrato (non leggibile in chiaro) |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🆘 Supporto e Debug
|
||
|
|
|
||
|
|
Se incontri problemi, fornisci:
|
||
|
|
```bash
|
||
|
|
# 1. Versioni esatte
|
||
|
|
php /var/www/glpi/bin/console glpi:version
|
||
|
|
composer --version
|
||
|
|
python3 --version
|
||
|
|
|
||
|
|
# 2. Log rilevanti
|
||
|
|
tail -50 /var/log/glpi/php-errors.log
|
||
|
|
tail -50 /var/log/glpi_agent_netconfig.log
|
||
|
|
journalctl -u glpi-netconfig.service -n 50
|
||
|
|
|
||
|
|
# 3. Output composer
|
||
|
|
composer diagnose
|
||
|
|
composer show symfony/diff
|
||
|
|
|
||
|
|
# 4. Test endpoint API
|
||
|
|
curl -v http://localhost/glpi/apirest.php/initSession -H "App-Token: YOUR_TOKEN"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
> 📌 **Nota Finale**: Questo plugin è progettato per GLPI 11.0.6+. Per aggiornamenti futuri di GLPI, verifica la compatibilità degli hook e delle API in [https://github.com/glpi-project/glpi/blob/11.0/CHANGELOG.md](https://github.com/glpi-project/glpi/blob/11.0/CHANGELOG.md).
|
||
|
|
|
||
|
|
**Buon backup! 🔄🔐**
|