# πŸ“¦ 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 '>=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 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 __('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 '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 'error', 'message' => 'Invalid JSON']); exit; } $handler = new \PluginNetconfig\Agent\Receive(); echo json_encode($handler($input, 'POST', '/plugin/netconfig/agent')); exit; ``` --- ### πŸ”Ή `ajax/export.php` ```php 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
{% for config in configs %} {% else %} {% endfor %}
{{ __('Date') }} {{ __('Hash') }} {{ __('User') }} {{ __('Actions') }}
{{ config.created_at }} {{ config.config_hash|slice(0, 16) }}... {{ config.users_id > 0 ? config.users_id|getUserName : __('System') }}
{% if can_export %} {% endif %} {% if config.diff_html is defined and config.diff_html is not empty %} {% endif %}
{{ __('No configuration saved yet') }}
{% if can_create %}
{% endif %}
``` --- ## 🐍 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 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 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! πŸ”„πŸ”**