Files
netconfig/REPORT.md
T
2026-05-22 13:55:01 +02:00

42 KiB

📦 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
  2. Struttura File
  3. Codice Completo dei File
  4. Script Python per GLPI Agent
  5. Configurazione Scheduler (Cron/Supervisor/Systemd)
  6. Client API REST GLPI 11
  7. Installazione Passo-Passo
  8. Risoluzione Problemi Composer
  9. Sicurezza e Best Practice
  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

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
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
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

{
  "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

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
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
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
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
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

<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.

#!/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

# 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:

# 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)

# /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)

# /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"
# 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)

# /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
# /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
# 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
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

# 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

# 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:

# 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:

# 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

# 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

# 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:

# 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.

Buon backup! 🔄🔐