Files
netconfig/REPORT.md
T

1302 lines
42 KiB
Markdown
Raw Normal View History

2026-05-22 13:55:01 +02:00
# 📦 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! 🔄🔐**